| | | 1 | | package cli |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "errors" |
| | | 5 | | "fmt" |
| | | 6 | | "os" |
| | | 7 | | "strings" |
| | | 8 | | |
| | | 9 | | "github.com/spf13/cobra" |
| | | 10 | | |
| | | 11 | | adapterconfig "github.com/jedi-knights/go-semantic-release/internal/adapters/config" |
| | | 12 | | "github.com/jedi-knights/go-semantic-release/internal/di" |
| | | 13 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 14 | | "github.com/jedi-knights/go-semantic-release/internal/platform" |
| | | 15 | | ) |
| | | 16 | | |
| | | 17 | | // ErrQuietExit signals that the command has already printed its own error output |
| | | 18 | | // and the caller (main) should exit with a non-zero code without printing anything |
| | | 19 | | // further. This avoids duplicate error messages for commands like lint and verify |
| | | 20 | | // that report structured output before failing. |
| | | 21 | | var ErrQuietExit = errors.New("quiet exit") |
| | | 22 | | |
| | | 23 | | // rootOptions holds all flag values bound to the root command. Using a struct |
| | | 24 | | // rather than package-level vars eliminates the shared-mutable-state data race |
| | | 25 | | // that occurs when Execute is called concurrently (e.g., in parallel tests). |
| | | 26 | | type rootOptions struct { |
| | | 27 | | // Original semantic-release flags. |
| | | 28 | | branches []string |
| | | 29 | | repositoryURL string |
| | | 30 | | tagFormat string |
| | | 31 | | plugins []string |
| | | 32 | | extends []string |
| | | 33 | | dryRun bool |
| | | 34 | | ciFlag bool |
| | | 35 | | noCIFlag bool |
| | | 36 | | debug bool |
| | | 37 | | |
| | | 38 | | // Extension flags (Go-specific). |
| | | 39 | | cfgFile string |
| | | 40 | | project string |
| | | 41 | | jsonOut bool |
| | | 42 | | interactive bool |
| | | 43 | | noInteractive bool |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | // NewRootCmd creates the root cobra command. |
| | | 47 | | // The default action (no subcommand) runs the release, matching the original semantic-release behavior. |
| | 5 | 48 | | func NewRootCmd() *cobra.Command { |
| | 5 | 49 | | opts := &rootOptions{} |
| | 5 | 50 | | |
| | 5 | 51 | | root := &cobra.Command{ |
| | 5 | 52 | | Use: "semantic-release [options]", |
| | 5 | 53 | | Short: "Run automated package publishing", |
| | 5 | 54 | | Long: `semantic-release automates the whole package release workflow including: |
| | 5 | 55 | | determining the next version number, generating the release notes, |
| | 5 | 56 | | and publishing the package. |
| | 5 | 57 | | |
| | 5 | 58 | | This is a native Go implementation compatible with the semantic-release CLI.`, |
| | 5 | 59 | | // SilenceUsage prevents Cobra from printing the usage string on every RunE error. |
| | 5 | 60 | | // SilenceErrors prevents Cobra from printing the error itself; main handles that |
| | 5 | 61 | | // so it can filter ErrQuietExit without double-printing. |
| | 5 | 62 | | SilenceUsage: true, |
| | 5 | 63 | | SilenceErrors: true, |
| | 0 | 64 | | RunE: func(cmd *cobra.Command, args []string) error { |
| | 0 | 65 | | return runRelease(cmd, args, opts) |
| | 0 | 66 | | }, |
| | | 67 | | } |
| | | 68 | | |
| | | 69 | | // Signal-aware context is wired in main via ExecuteContext so the stop() |
| | | 70 | | // function is always deferred regardless of whether the command succeeds or fails. |
| | | 71 | | |
| | | 72 | | // Flags matching the original semantic-release CLI (persistent so subcommands inherit them). |
| | 5 | 73 | | pf := root.PersistentFlags() |
| | 5 | 74 | | pf.StringArrayVarP(&opts.branches, "branches", "b", nil, "Git branches to release from (main/master are default; for o |
| | 5 | 75 | | pf.StringVarP(&opts.repositoryURL, "repository-url", "r", "", "Git repository URL") |
| | 5 | 76 | | pf.StringVarP(&opts.tagFormat, "tag-format", "t", "", "Git tag format") |
| | 5 | 77 | | pf.StringArrayVarP(&opts.plugins, "plugins", "p", nil, "Plugins") |
| | 5 | 78 | | pf.StringArrayVarP(&opts.extends, "extends", "e", nil, "Shareable configurations") |
| | 5 | 79 | | pf.BoolVarP(&opts.dryRun, "dry-run", "d", false, "Skip publishing") |
| | 5 | 80 | | pf.BoolVar(&opts.ciFlag, "ci", false, "Toggle CI verifications") |
| | 5 | 81 | | pf.BoolVar(&opts.noCIFlag, "no-ci", false, "Skip CI verifications") |
| | 5 | 82 | | pf.BoolVar(&opts.debug, "debug", false, "Output debugging information") |
| | 5 | 83 | | |
| | 5 | 84 | | // Extension flags (Go-specific, also persistent). |
| | 5 | 85 | | pf.StringVar(&opts.cfgFile, "config", "", "config file (default: .semantic-release.yaml)") |
| | 5 | 86 | | pf.StringVar(&opts.project, "project", "", "target a specific project in a monorepo") |
| | 5 | 87 | | pf.BoolVar(&opts.jsonOut, "json", false, "output in JSON format") |
| | 5 | 88 | | pf.BoolVar(&opts.interactive, "interactive", false, "prompt for confirmation before release") |
| | 5 | 89 | | pf.BoolVar(&opts.noInteractive, "no-interactive", false, "disable interactive prompts") |
| | 5 | 90 | | |
| | 5 | 91 | | // Subcommands are Go-specific extensions beyond the original semantic-release. |
| | 5 | 92 | | root.AddCommand( |
| | 5 | 93 | | newPlanCmd(opts), |
| | 5 | 94 | | newVersionCmd(opts), |
| | 5 | 95 | | newChangelogCmd(opts), |
| | 5 | 96 | | newDetectProjectsCmd(opts), |
| | 5 | 97 | | newVerifyCmd(opts), |
| | 5 | 98 | | newConfigCmd(), |
| | 5 | 99 | | newLintCmd(opts), |
| | 5 | 100 | | ) |
| | 5 | 101 | | |
| | 5 | 102 | | // Enforce mutually exclusive flag pairs. Cobra 1.5+ fires a clear error |
| | 5 | 103 | | // message when both flags in a pair are set on the same invocation. |
| | 5 | 104 | | // Note: these checks apply to the root command; for subcommands that |
| | 5 | 105 | | // inherit the persistent flags, applyFlagAndEnvOverrides handles conflicts |
| | 5 | 106 | | // via switch/case with first-wins semantics (ciFlag wins over noCIFlag when both are set). |
| | 5 | 107 | | root.MarkFlagsMutuallyExclusive("ci", "no-ci") |
| | 5 | 108 | | root.MarkFlagsMutuallyExclusive("interactive", "no-interactive") |
| | 5 | 109 | | |
| | 5 | 110 | | return root |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | // buildContainerWithWorkDir creates a DI container and also returns the resolved |
| | | 114 | | // working directory so callers do not need a second os.Getwd() call. |
| | 17 | 115 | | func buildContainerWithWorkDir(opts *rootOptions) (*di.Container, string, error) { |
| | 17 | 116 | | provider := adapterconfig.NewViperProvider() |
| | 17 | 117 | | cfg, err := provider.Load(opts.cfgFile) |
| | 0 | 118 | | if err != nil { |
| | 0 | 119 | | return nil, "", fmt.Errorf("loading config: %w", err) |
| | 0 | 120 | | } |
| | | 121 | | |
| | | 122 | | // Apply CLI flag and environment overrides. |
| | 17 | 123 | | applyFlagAndEnvOverrides(&cfg, opts) |
| | 17 | 124 | | |
| | 17 | 125 | | workDir, err := os.Getwd() |
| | 0 | 126 | | if err != nil { |
| | 0 | 127 | | return nil, "", fmt.Errorf("getting working directory: %w", err) |
| | 0 | 128 | | } |
| | | 129 | | |
| | 17 | 130 | | container, err := di.NewContainer(cfg, workDir) |
| | 0 | 131 | | if err != nil { |
| | 0 | 132 | | return nil, "", fmt.Errorf("creating container: %w", err) |
| | 0 | 133 | | } |
| | 0 | 134 | | if cfg.Debug { |
| | 0 | 135 | | container.WithLogger(platform.NewConsoleLogger(os.Stderr, platform.LogDebug)) |
| | 0 | 136 | | } |
| | | 137 | | |
| | 17 | 138 | | return container, workDir, nil |
| | | 139 | | } |
| | | 140 | | |
| | | 141 | | // applyFlagAndEnvOverrides applies CLI flag values and environment-detected settings |
| | | 142 | | // (e.g. CI auto-detection) on top of the loaded configuration. |
| | 26 | 143 | | func applyFlagAndEnvOverrides(cfg *domain.Config, opts *rootOptions) { |
| | 1 | 144 | | if opts.dryRun { |
| | 1 | 145 | | cfg.DryRun = true |
| | 1 | 146 | | } |
| | 1 | 147 | | if opts.debug { |
| | 1 | 148 | | cfg.Debug = true |
| | 1 | 149 | | } |
| | | 150 | | |
| | | 151 | | // --branches / -b overrides config branches. |
| | 1 | 152 | | if len(opts.branches) > 0 { |
| | 1 | 153 | | cfg.Branches = parseBranchFlags(opts.branches) |
| | 1 | 154 | | } |
| | | 155 | | |
| | | 156 | | // --repository-url / -r overrides config. |
| | 1 | 157 | | if opts.repositoryURL != "" { |
| | 1 | 158 | | cfg.RepositoryURL = opts.repositoryURL |
| | 1 | 159 | | } |
| | | 160 | | |
| | | 161 | | // --tag-format / -t overrides config. |
| | 1 | 162 | | if opts.tagFormat != "" { |
| | 1 | 163 | | cfg.TagFormat = opts.tagFormat |
| | 1 | 164 | | } |
| | | 165 | | |
| | | 166 | | // --extends / -e overrides config. |
| | 1 | 167 | | if len(opts.extends) > 0 { |
| | 1 | 168 | | cfg.Extends = opts.extends |
| | 1 | 169 | | } |
| | | 170 | | |
| | | 171 | | // --plugins / -p overrides config. |
| | 1 | 172 | | if len(opts.plugins) > 0 { |
| | 1 | 173 | | cfg.Plugins = opts.plugins |
| | 1 | 174 | | } |
| | | 175 | | |
| | | 176 | | // CI detection: --ci forces CI mode, --no-ci disables it, otherwise |
| | | 177 | | // auto-detect via environment variables. platform.IsCI() is only called |
| | | 178 | | // when neither flag is set to avoid unnecessary env-var inspection. |
| | 26 | 179 | | var isCI bool |
| | 26 | 180 | | switch { |
| | 23 | 181 | | case opts.ciFlag: |
| | 23 | 182 | | isCI = true |
| | 3 | 183 | | case opts.noCIFlag: |
| | 3 | 184 | | isCI = false |
| | 0 | 185 | | default: |
| | 0 | 186 | | isCI = platform.IsCI() |
| | | 187 | | } |
| | 26 | 188 | | cfg.CI = isCI |
| | 26 | 189 | | |
| | 26 | 190 | | // When not in CI and dry-run wasn't explicitly set, default to dry-run so |
| | 26 | 191 | | // local runs never accidentally publish. Users who intend a real local release |
| | 26 | 192 | | // must pass --no-ci explicitly. |
| | 26 | 193 | | // Note: the user-facing warning for this auto dry-run is printed by runRelease |
| | 26 | 194 | | // (not here) so it only appears for the release command, not plan/lint/verify/etc. |
| | 3 | 195 | | if !isCI && !opts.dryRun { |
| | 3 | 196 | | cfg.DryRun = true |
| | 3 | 197 | | } |
| | | 198 | | } |
| | | 199 | | |
| | | 200 | | // parseBranchFlags converts CLI branch strings into BranchPolicy entries. |
| | | 201 | | // Empty strings after trimming are silently dropped — this is intentional: a |
| | | 202 | | // user passing --branches "" or trailing commas should not create a policy with |
| | | 203 | | // a blank branch name. |
| | 7 | 204 | | func parseBranchFlags(branchNames []string) []domain.BranchPolicy { |
| | 7 | 205 | | policies := make([]domain.BranchPolicy, 0, len(branchNames)) |
| | 7 | 206 | | for _, name := range branchNames { |
| | 9 | 207 | | // Support comma-separated values. |
| | 9 | 208 | | for _, n := range strings.Split(name, ",") { |
| | 10 | 209 | | n = strings.TrimSpace(n) |
| | 2 | 210 | | if n == "" { |
| | 2 | 211 | | continue |
| | | 212 | | } |
| | | 213 | | // IsDefault is set for the two canonical branch names used by most |
| | | 214 | | // Git hosting platforms. Any other name passed via --branches gets |
| | | 215 | | // IsDefault: false. Users who use a different default branch name |
| | | 216 | | // (e.g. "trunk") must set is_default in the config file instead. |
| | 8 | 217 | | policies = append(policies, domain.BranchPolicy{ |
| | 8 | 218 | | Name: n, |
| | 8 | 219 | | IsDefault: n == "main" || n == "master", |
| | 8 | 220 | | }) |
| | | 221 | | } |
| | | 222 | | } |
| | 7 | 223 | | return policies |
| | | 224 | | } |