Skip to content

Commit 84f5046

Browse files
committed
feat: Implement --skip flag for pipeline steps and --list-steps for detailed help, along with adding Help() methods to all pipeline steps.
1 parent 3a79fd9 commit 84f5046

File tree

21 files changed

+320
-78
lines changed

21 files changed

+320
-78
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## [0.2.0] - 2026-03-24
10+
11+
### Added
12+
- `--skip` CLI flag to skip pipeline steps from the command line (comma-separated, e.g. `--skip publish,test`), merged with config `skip` list with deduplication
13+
- `--list-steps` CLI flag to show detailed descriptions of all pipeline steps
14+
- `Help()` method on `Step` interface providing per-step documentation (what it does, per-language commands, prerequisites, hooks, destructive warnings)
15+
- Available steps summary in `--help` output, auto-generated from step definitions
16+
- `StepInfo` / `StepInfoList` for programmatic step metadata access
17+
18+
### Changed
19+
- Skip message in pipeline output changed from "skipped by config" to "skipped" (source-agnostic)
20+
- `Config.Merge()` now accepts `cliSkip` parameter for CLI skip merge
21+
- Consolidated step list into single `buildAllSteps()` function (was duplicated 3x)
22+
- Help text and step validation now derived from step definitions (single source of truth)
23+
24+
### Fixed
25+
- `StepNames` in `pipeline/step.go` was missing `"verify"` step
26+
927
## [0.1.1] - 2026-03-22
1028

1129
### Changed

README.md

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ Steps marked as **destructive** (git_tag, github_release, publish) prompt for co
6262
unirelease [path] [flags]
6363
6464
Flags:
65-
--dry-run Preview pipeline without executing
66-
--step <name> Run only a specific step
67-
--type <type> Override auto-detection (rust|go|node|bun|python)
68-
-v, --version Print version
65+
--dry-run Preview pipeline without executing
66+
--step <name> Run only a specific step
67+
--skip <steps> Steps to skip (comma-separated, e.g. --skip publish,test)
68+
--list-steps Show detailed descriptions of all pipeline steps
69+
--type <type> Override auto-detection (rust|go|node|bun|python)
70+
-v, --version Print version
6971
-V, --set-version <X.Y.Z> Override detected version
70-
-y, --yes Non-interactive mode (skip confirmations)
72+
-y, --yes Non-interactive mode (skip confirmations)
7173
```
7274

7375
### Examples
@@ -85,6 +87,12 @@ unirelease --set-version 2.0.0
8587
# Run only the build step
8688
unirelease --step build
8789

90+
# Skip specific steps from CLI
91+
unirelease --skip publish,test
92+
93+
# Show detailed help for all steps
94+
unirelease --list-steps
95+
8896
# Force project type (useful for monorepos)
8997
unirelease --type rust
9098

@@ -242,38 +250,10 @@ After the pipeline completes, unirelease displays a summary with:
242250
Release complete!
243251
```
244252

245-
## Project Structure
246-
247-
```
248-
unirelease/
249-
├── cmd/root.go # CLI entry point (Cobra)
250-
├── internal/
251-
│ ├── changelog/ # CHANGELOG.md parser
252-
│ ├── config/ # .unirelease.toml loader
253-
│ ├── detector/ # Project type & version detection
254-
│ ├── git/ # Git operations (tag, push, status)
255-
│ ├── github/ # GitHub API client (go-github)
256-
│ ├── pipeline/
257-
│ │ ├── engine.go # Pipeline orchestrator
258-
│ │ ├── context.go # Provider interface & shared context
259-
│ │ └── steps/ # 11 pipeline step implementations
260-
│ ├── providers/ # Language-specific providers
261-
│ │ ├── rust.go
262-
│ │ ├── go.go
263-
│ │ ├── node.go
264-
│ │ ├── bun.go
265-
│ │ └── python.go
266-
│ ├── runner/ # Command executor (dry-run aware)
267-
│ └── ui/ # Colored output & interactive prompts
268-
├── e2e_test.go # End-to-end pipeline tests
269-
├── go.mod
270-
└── main.go
271-
```
272-
273253
## Development
274254

275255
```bash
276-
# Run all tests (183 tests across 10 packages)
256+
# Run all tests
277257
go test ./...
278258

279259
# Run with verbose output

cmd/root.go

Lines changed: 95 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,65 @@ import (
1818
)
1919

2020
var (
21-
flagStep string
22-
flagYes bool
23-
flagDryRun bool
24-
flagVersion string
25-
flagType string
21+
flagStep string
22+
flagSkip []string
23+
flagYes bool
24+
flagDryRun bool
25+
flagVersion string
26+
flagType string
27+
flagListSteps bool
2628
)
2729

28-
// Valid pipeline step names.
29-
var validSteps = []string{
30-
"detect", "read_version", "verify_env", "check_git_status",
31-
"clean", "build", "test", "verify", "git_tag", "github_release", "publish",
30+
var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
31+
32+
// buildAllSteps returns the canonical pipeline step list.
33+
func buildAllSteps() []pipeline.Step {
34+
return []pipeline.Step{
35+
&steps.DetectStep{},
36+
&steps.ReadVersionStep{},
37+
&steps.VerifyEnvStep{},
38+
&steps.CheckGitStatusStep{},
39+
&steps.CleanStep{},
40+
&steps.BuildStep{},
41+
&steps.TestStep{},
42+
&steps.VerifyStep{},
43+
&steps.GitTagStep{},
44+
&steps.GitHubReleaseStep{},
45+
&steps.PublishStep{},
46+
}
3247
}
3348

34-
var semverRegex = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
49+
// validStepNames returns the list of valid step names derived from buildAllSteps.
50+
func validStepNames() []string {
51+
allSteps := buildAllSteps()
52+
names := make([]string, len(allSteps))
53+
for i, s := range allSteps {
54+
names[i] = s.Name()
55+
}
56+
return names
57+
}
58+
59+
// buildLongHelp generates the Long help text from the step list.
60+
func buildLongHelp() string {
61+
var b strings.Builder
62+
b.WriteString("Auto-detects project type (Rust, Node, Bun, Python, Go) and runs a unified release pipeline.\n")
63+
b.WriteString("\nAvailable steps (in execution order):\n")
64+
for i, step := range buildAllSteps() {
65+
tag := ""
66+
if step.Destructive() {
67+
tag = " [destructive]"
68+
}
69+
fmt.Fprintf(&b, " %2d. %-17s %s%s\n", i+1, step.Name(), step.Description(), tag)
70+
}
71+
b.WriteString("\nUse --list-steps for detailed descriptions of each step.")
72+
return b.String()
73+
}
3574

3675
var rootCmd = &cobra.Command{
37-
Use: "unirelease [path]",
38-
Short: "Unified release pipeline for any project",
39-
Long: "Auto-detects project type (Rust, Node, Bun, Python, Go) and runs a unified release pipeline.",
40-
Args: cobra.MaximumNArgs(1),
41-
RunE: runRelease,
76+
Use: "unirelease [path]",
77+
Short: "Unified release pipeline for any project",
78+
Args: cobra.MaximumNArgs(1),
79+
RunE: runRelease,
4280
SilenceUsage: true,
4381
SilenceErrors: true,
4482
}
@@ -49,11 +87,14 @@ func SetVersion(v string) {
4987
}
5088

5189
func init() {
90+
rootCmd.Long = buildLongHelp()
5291
rootCmd.Flags().StringVar(&flagStep, "step", "", "Run only a specific pipeline step")
92+
rootCmd.Flags().StringSliceVar(&flagSkip, "skip", nil, "Steps to skip (comma-separated, e.g. --skip publish,test)")
5393
rootCmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Non-interactive mode (skip confirmations)")
5494
rootCmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Preview pipeline without executing")
5595
rootCmd.Flags().StringVarP(&flagVersion, "set-version", "V", "", "Override detected version (e.g. 1.2.3)")
5696
rootCmd.Flags().StringVar(&flagType, "type", "", "Override auto-detection (rust|node|bun|python|go)")
97+
rootCmd.Flags().BoolVar(&flagListSteps, "list-steps", false, "Show detailed descriptions of all pipeline steps")
5798
}
5899

59100
func Execute() error {
@@ -93,17 +134,14 @@ func resolveProjectDir(args []string) (string, error) {
93134

94135
// validateFlags validates all flag values before running the pipeline.
95136
func validateFlags() error {
96-
if flagStep != "" {
97-
valid := false
98-
for _, s := range validSteps {
99-
if flagStep == s {
100-
valid = true
101-
break
102-
}
103-
}
104-
if !valid {
105-
return fmt.Errorf("invalid step %q (valid: %s)", flagStep, strings.Join(validSteps, ", "))
106-
}
137+
names := validStepNames()
138+
nameSet := make(map[string]bool, len(names))
139+
for _, s := range names {
140+
nameSet[s] = true
141+
}
142+
143+
if flagStep != "" && !nameSet[flagStep] {
144+
return fmt.Errorf("invalid step %q (valid: %s)", flagStep, strings.Join(names, ", "))
107145
}
108146

109147
if flagType != "" {
@@ -124,6 +162,12 @@ func validateFlags() error {
124162
}
125163
}
126164

165+
for _, s := range flagSkip {
166+
if !nameSet[s] {
167+
return fmt.Errorf("invalid skip step %q (valid: %s)", s, strings.Join(names, ", "))
168+
}
169+
}
170+
127171
if flagVersion != "" && !semverRegex.MatchString(flagVersion) {
128172
return fmt.Errorf("invalid version %q (expected format: X.Y.Z, e.g. 1.2.3)", flagVersion)
129173
}
@@ -132,6 +176,12 @@ func validateFlags() error {
132176
}
133177

134178
func runRelease(cmd *cobra.Command, args []string) error {
179+
// List steps mode
180+
if flagListSteps {
181+
printStepDetails()
182+
return nil
183+
}
184+
135185
// Validate flags
136186
if err := validateFlags(); err != nil {
137187
return err
@@ -150,7 +200,7 @@ func runRelease(cmd *cobra.Command, args []string) error {
150200
}
151201

152202
// Merge CLI flags into config
153-
cfg.Merge(flagType)
203+
cfg.Merge(flagType, flagSkip)
154204

155205
// Create UI
156206
u := ui.New()
@@ -172,19 +222,7 @@ func runRelease(cmd *cobra.Command, args []string) error {
172222
}
173223

174224
// Build step list
175-
allSteps := []pipeline.Step{
176-
&steps.DetectStep{},
177-
&steps.ReadVersionStep{},
178-
&steps.VerifyEnvStep{},
179-
&steps.CheckGitStatusStep{},
180-
&steps.CleanStep{},
181-
&steps.BuildStep{},
182-
&steps.TestStep{},
183-
&steps.VerifyStep{},
184-
&steps.GitTagStep{},
185-
&steps.GitHubReleaseStep{},
186-
&steps.PublishStep{},
187-
}
225+
allSteps := buildAllSteps()
188226

189227
// Wrap detect step to also resolve the provider after detection
190228
wrappedSteps := make([]pipeline.Step, len(allSteps))
@@ -204,6 +242,7 @@ type detectAndResolveStep struct {
204242

205243
func (s *detectAndResolveStep) Name() string { return s.inner.Name() }
206244
func (s *detectAndResolveStep) Description() string { return s.inner.Description() }
245+
func (s *detectAndResolveStep) Help() string { return s.inner.Help() }
207246
func (s *detectAndResolveStep) Destructive() bool { return s.inner.Destructive() }
208247

209248
func (s *detectAndResolveStep) Execute(ctx *pipeline.Context) error {
@@ -228,3 +267,19 @@ func resolveProvider(ctx *pipeline.Context) error {
228267
ctx.Provider = provider
229268
return nil
230269
}
270+
271+
func printStepDetails() {
272+
infoList := pipeline.StepInfoList(buildAllSteps())
273+
274+
fmt.Println("Pipeline Steps:")
275+
fmt.Println()
276+
for i, info := range infoList {
277+
marker := " "
278+
if info.Destructive {
279+
marker = "!"
280+
}
281+
fmt.Printf(" %2d. [%s] %-17s %s\n", i+1, marker, info.Name, info.Description)
282+
fmt.Printf(" %s\n\n", info.Help)
283+
}
284+
fmt.Println("Legend: [!] = destructive (prompts for confirmation, use --yes to skip)")
285+
}

cmd/root_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package cmd
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
8+
9+
"github.com/aiperceivable/unirelease/internal/pipeline"
710
)
811

912
func TestValidateFlags_ValidStep(t *testing.T) {
@@ -75,6 +78,62 @@ func TestValidateFlags_InvalidVersion(t *testing.T) {
7578
}
7679
}
7780

81+
func TestValidateFlags_ValidSkip(t *testing.T) {
82+
flagStep = ""
83+
flagType = ""
84+
flagVersion = ""
85+
flagSkip = []string{"publish", "test"}
86+
defer func() { flagSkip = nil }()
87+
88+
if err := validateFlags(); err != nil {
89+
t.Errorf("unexpected error: %v", err)
90+
}
91+
}
92+
93+
func TestValidateFlags_InvalidSkip(t *testing.T) {
94+
flagStep = ""
95+
flagType = ""
96+
flagVersion = ""
97+
flagSkip = []string{"publish", "bogus"}
98+
defer func() { flagSkip = nil }()
99+
100+
err := validateFlags()
101+
if err == nil {
102+
t.Fatal("expected error for invalid skip step")
103+
}
104+
}
105+
106+
func TestBuildAllSteps_MatchesStepNames(t *testing.T) {
107+
allSteps := buildAllSteps()
108+
stepNames := pipeline.StepNames
109+
110+
if len(allSteps) != len(stepNames) {
111+
t.Fatalf("buildAllSteps() has %d steps, StepNames has %d", len(allSteps), len(stepNames))
112+
}
113+
for i, step := range allSteps {
114+
if step.Name() != stepNames[i] {
115+
t.Errorf("step %d: buildAllSteps() has %q, StepNames has %q", i, step.Name(), stepNames[i])
116+
}
117+
}
118+
}
119+
120+
func TestBuildAllSteps_HelpNotEmpty(t *testing.T) {
121+
for _, step := range buildAllSteps() {
122+
if step.Help() == "" {
123+
t.Errorf("step %q has empty Help()", step.Name())
124+
}
125+
}
126+
}
127+
128+
func TestBuildLongHelp_ContainsAllSteps(t *testing.T) {
129+
helpText := buildLongHelp()
130+
for _, step := range buildAllSteps() {
131+
if !strings.Contains(helpText, step.Name()) {
132+
t.Errorf("Long help missing step %q", step.Name())
133+
}
134+
}
135+
}
136+
78137
func TestResolveProjectDir_Explicit(t *testing.T) {
79138
dir := t.TempDir()
80139
result, err := resolveProjectDir([]string{dir})

e2e_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type detectAndResolveStep struct {
5858

5959
func (s *detectAndResolveStep) Name() string { return s.inner.Name() }
6060
func (s *detectAndResolveStep) Description() string { return s.inner.Description() }
61+
func (s *detectAndResolveStep) Help() string { return s.inner.Help() }
6162
func (s *detectAndResolveStep) Destructive() bool { return s.inner.Destructive() }
6263

6364
func (s *detectAndResolveStep) Execute(ctx *pipeline.Context) error {

0 commit comments

Comments
 (0)