mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-02 23:57:59 +00:00
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:
parent
e6f82ed91b
commit
b6fd4072bf
@ -23,6 +23,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
@ -57,6 +58,15 @@ type moduleGroup struct {
|
|||||||
bins []binary
|
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 ────────────────────────────────────────────────────────────
|
// ── Entry point ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -102,14 +112,16 @@ func usage() {
|
|||||||
|
|
||||||
func runRelease(args []string) {
|
func runRelease(args []string) {
|
||||||
fs := flag.NewFlagSet("monorel release", flag.ExitOnError)
|
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(&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(&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() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(os.Stderr, "usage: monorel release [options] <binary-path>...")
|
fmt.Fprintln(os.Stderr, "usage: monorel release [options] <binary-path>...")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
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, "Updates .goreleaser.yaml next to each module's go.mod and runs the")
|
||||||
fmt.Fprintln(os.Stderr, "ready-to-review bash release script to stdout.")
|
fmt.Fprintln(os.Stderr, "release steps interactively (prompt per step by default).")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Examples:")
|
fmt.Fprintln(os.Stderr, "Examples:")
|
||||||
fmt.Fprintln(os.Stderr, " monorel release . # single binary at module root")
|
fmt.Fprintln(os.Stderr, " monorel release . # single binary at module root")
|
||||||
@ -140,11 +152,6 @@ func runRelease(args []string) {
|
|||||||
|
|
||||||
cwd, _ := os.Getwd()
|
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 {
|
for i, group := range groups {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
fmt.Fprintln(os.Stderr)
|
fmt.Fprintln(os.Stderr)
|
||||||
@ -152,7 +159,7 @@ func runRelease(args []string) {
|
|||||||
printGroupHeader(cwd, group)
|
printGroupHeader(cwd, group)
|
||||||
relPath, _ := filepath.Rel(cwd, group.root)
|
relPath, _ := filepath.Rel(cwd, group.root)
|
||||||
relPath = filepath.ToSlash(relPath)
|
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
|
// 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
|
// 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.
|
// 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
|
modRoot := group.root
|
||||||
bins := group.bins
|
bins := group.bins
|
||||||
|
|
||||||
@ -1077,9 +1084,18 @@ func processModule(group *moduleGroup, relPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
headSHA := mustRunIn(modRoot, "git", "rev-parse", "HEAD")
|
headSHA := mustRunIn(modRoot, "git", "rev-parse", "HEAD")
|
||||||
printModuleScript(relPath, projectName, bins,
|
|
||||||
version, currentTag, prevTag, repoPath, headSHA,
|
printModuleHeader(relPath, projectName, bins,
|
||||||
releaseNotes, isPreRelease, needsNewTag, isDirty)
|
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 ────────────────────────────────────────────────────
|
// ── Version computation ────────────────────────────────────────────────────
|
||||||
@ -1280,45 +1296,18 @@ func yamlLooksCorrect(content string, bins []binary) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Release script generation ──────────────────────────────────────────────
|
// ── Release step runner ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// printModuleScript emits one module's release steps to stdout.
|
// printModuleHeader writes the informational header block for one module to
|
||||||
//
|
// stdout. It is always shown regardless of dry-run / yes mode.
|
||||||
// All paths in the generated script are relative to relPath so that the
|
func printModuleHeader(
|
||||||
// script can be run from the directory where monorel was invoked:
|
relPath, projectName string, bins []binary,
|
||||||
// - git commands use relPath/ as the pathspec (instead of ./)
|
version, currentTag, prevTag, repoPath string,
|
||||||
// - goreleaser is wrapped in ( cd "relPath" && goreleaser ... ) when needed
|
isDirty bool,
|
||||||
// - 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,
|
|
||||||
) {
|
) {
|
||||||
line := func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
|
line := func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
|
||||||
blank := func() { fmt.Println() }
|
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()
|
blank()
|
||||||
rule := strings.Repeat("═", 54)
|
rule := strings.Repeat("═", 54)
|
||||||
fmt.Printf("# %s\n", rule)
|
fmt.Printf("# %s\n", rule)
|
||||||
@ -1354,59 +1343,194 @@ func printModuleScript(
|
|||||||
line("# %-16s (none — first release)", "Previous tag:")
|
line("# %-16s (none — first release)", "Previous tag:")
|
||||||
}
|
}
|
||||||
line("# %-16s %s", "Repo:", repoPath)
|
line("# %-16s %s", "Repo:", repoPath)
|
||||||
|
blank()
|
||||||
|
}
|
||||||
|
|
||||||
section("Step 1: Environment variables")
|
// buildModuleSteps constructs the ordered list of release steps for one module.
|
||||||
line("export VERSION=%q", version)
|
func buildModuleSteps(
|
||||||
|
modRoot, relPath, projectName string, bins []binary,
|
||||||
if needsNewTag {
|
version, currentTag, repoPath, headSHA string,
|
||||||
section("Step 2: Create git tag")
|
releaseNotes string,
|
||||||
line("git tag %q", currentTag)
|
isPreRelease, needsNewTag bool,
|
||||||
line("# To undo: git tag -d %q", currentTag)
|
) []releaseStep {
|
||||||
}
|
distDir := filepath.Join(modRoot, "dist")
|
||||||
|
var distRelDir string
|
||||||
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.")
|
|
||||||
if relPath == "." {
|
if relPath == "." {
|
||||||
line("goreleaser release --clean --skip=validate,announce")
|
distRelDir = "./dist"
|
||||||
} else {
|
} else {
|
||||||
line("(")
|
distRelDir = relPath + "/dist"
|
||||||
line(" cd %q", relPath)
|
|
||||||
line(" goreleaser release --clean --skip=validate,announce")
|
|
||||||
line(")")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section("Step 5: Release notes")
|
|
||||||
line("%s=%s", notesVar, shellSingleQuote(releaseNotes))
|
|
||||||
|
|
||||||
section("Step 6: Create draft GitHub release")
|
|
||||||
tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:]
|
tagVersion := currentTag[strings.LastIndex(currentTag, "/")+1:]
|
||||||
title := projectName + " " + tagVersion
|
title := projectName + " " + tagVersion
|
||||||
line("gh release create %q \\", currentTag)
|
|
||||||
line(" --title %q \\", title)
|
var steps []releaseStep
|
||||||
line(" --notes \"${%s}\" \\", notesVar)
|
|
||||||
|
// 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 {
|
if isPreRelease {
|
||||||
line(" --prerelease \\")
|
ghCreateDisplay = append(ghCreateDisplay, " --prerelease \\")
|
||||||
}
|
}
|
||||||
line(" --draft \\")
|
ghCreateDisplay = append(ghCreateDisplay, " --draft \\")
|
||||||
line(" --target %q", headSHA)
|
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")
|
// Step: Upload artifacts (globs expanded at run time after goreleaser).
|
||||||
line("gh release upload %q \\", currentTag)
|
var uploadDisplay []string
|
||||||
|
uploadDisplay = append(uploadDisplay, fmt.Sprintf("gh release upload %q \\", currentTag))
|
||||||
for _, bin := range bins {
|
for _, bin := range bins {
|
||||||
line(" %s/%s_*.tar.gz \\", distDir, bin.name)
|
uploadDisplay = append(uploadDisplay,
|
||||||
line(" %s/%s_*.tar.zst \\", distDir, bin.name)
|
fmt.Sprintf(" %s/%s_*.tar.gz \\", distRelDir, bin.name),
|
||||||
line(" %s/%s_*.zip \\", distDir, 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)
|
uploadDisplay = append(uploadDisplay,
|
||||||
line(" --clobber")
|
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)")
|
// Step: Publish release.
|
||||||
line("gh release edit %q --draft=false", currentTag)
|
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 ────────────────────────────────────────────────────────────────
|
// ── 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) {
|
func fatalf(format string, args ...any) {
|
||||||
fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...)
|
fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user