diff --git a/README.md b/README.md index 3492d91..088e2ca 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,18 @@ Installs to `$(go env GOPATH)/bin`, which must be on your `PATH`. anvil detect # identify the project and its stack anvil build # deps, analyze, test, build (guided) anvil build --release --flavor prod +anvil build --until analyze # deps, then analyze +anvil build --only test # test phase only anvil sign # set up Android or iOS signing anvil build --sign # build and sign anvil upload # dry-run by default, pass --yes to perform ``` Useful flags: `--path` (project directory), `--target` (android or ios), -`--flavor`, `--release`, `--dry-run` (print the plan without running it), and -`--plain` (no TUI, for CI). Every command has `--help`. +`--flavor`, `--release`, `--until` (inclusive lifecycle prefix), `--only` (one +lifecycle phase), `--dry-run` (print the plan without running it), and `--plain` +(no TUI, for CI). `--until` and `--only` are mutually exclusive. Every command +has `--help`. anvil never puts secrets on the command line or in the repo. Keystore and store credentials come from a prompt or an environment variable, and a credential diff --git a/cmd/build.go b/cmd/build.go index 74a7466..798bdc6 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -26,6 +26,8 @@ var ( buildSign bool buildDryRun bool buildPlain bool + buildUntil string + buildOnly string ) var buildCmd = &cobra.Command{ @@ -43,10 +45,16 @@ func init() { buildCmd.Flags().BoolVar(&buildSign, "sign", false, "set up signing and produce a signed artifact") buildCmd.Flags().BoolVar(&buildDryRun, "dry-run", false, "print the steps without running them") buildCmd.Flags().BoolVar(&buildPlain, "plain", false, "plain line output instead of the interactive view") + buildCmd.Flags().StringVar(&buildUntil, "until", "", "run through this lifecycle phase") + buildCmd.Flags().StringVar(&buildOnly, "only", "", "run only this lifecycle phase") rootCmd.AddCommand(buildCmd) } func runBuild(cmd *cobra.Command, _ []string) error { + if buildUntil != "" && buildOnly != "" { + return errors.New("--until and --only cannot be used together") + } + chosen, err := resolveProject(cmd, buildPath) if err != nil { return err @@ -68,6 +76,11 @@ func runBuild(cmd *cobra.Command, _ []string) error { phases = append(append([]driver.Phase{}, driver.Phases...), extra...) } + phases, err = selectBuildPhases(phases, buildUntil, buildOnly) + if err != nil { + return err + } + if buildDryRun { printPlan(cmd, chosen, d, opts, phases) return nil @@ -75,6 +88,35 @@ func runBuild(cmd *cobra.Command, _ []string) error { return runPipeline(cmd, chosen.Path, d, opts, phases) } +func selectBuildPhases(available []driver.Phase, until, only string) ([]driver.Phase, error) { + if until != "" && only != "" { + return nil, errors.New("--until and --only cannot be used together") + } + if until == "" && only == "" { + return append([]driver.Phase{}, available...), nil + } + + selected := only + if selected == "" { + selected = until + } + for idx, phase := range available { + if phase.String() != selected { + continue + } + if only != "" { + return []driver.Phase{phase}, nil + } + return append([]driver.Phase{}, available[:idx+1]...), nil + } + + names := make([]string, len(available)) + for idx, phase := range available { + names[idx] = phase.String() + } + return nil, fmt.Errorf("unknown phase %q; available phases: %s", selected, strings.Join(names, ", ")) +} + func resolveProject(cmd *cobra.Command, path string) (detect.Project, error) { root, err := filepath.Abs(path) if err != nil { diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 0000000..f962585 --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "reflect" + "strings" + "testing" + + "github.com/openforge-oss/anvil/internal/driver" +) + +func TestSelectBuildPhases(t *testing.T) { + available := []driver.Phase{driver.Deps, driver.Analyze, driver.Test, driver.Build} + + tests := []struct { + name string + until string + only string + want []driver.Phase + wantErr string + }{ + {name: "all phases by default", want: available}, + {name: "inclusive prefix", until: "analyze", want: []driver.Phase{driver.Deps, driver.Analyze}}, + {name: "one phase", only: "test", want: []driver.Phase{driver.Test}}, + {name: "conflicting selectors", until: "test", only: "analyze", wantErr: "cannot be used together"}, + {name: "unknown phase", only: "sign", wantErr: "available phases: deps, analyze, test, build"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := selectBuildPhases(available, tt.until, tt.only) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("selectBuildPhases() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("phases = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSelectBuildPhasesIncludesOptionalSign(t *testing.T) { + available := append(append([]driver.Phase{}, driver.Phases...), driver.Sign) + + got, err := selectBuildPhases(available, "", "sign") + if err != nil { + t.Fatalf("selectBuildPhases() error = %v", err) + } + if !reflect.DeepEqual(got, []driver.Phase{driver.Sign}) { + t.Fatalf("phases = %v, want [sign]", got) + } +} diff --git a/tasks/todo.md b/tasks/todo.md index b093db1..53cab49 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -106,3 +106,17 @@ and `scoop-bucket` repos plus a `HOMEBREW_TAP_TOKEN` secret before the first release tag. Deferred: App Store submission metadata, Play staged rollout, fastlane back-ends, `anvil build --upload`, npm OIDC. This completes the core detect -> build -> sign -> upload pipeline. + +## Issue 9: lifecycle phase selection + +- [x] Add mutually exclusive `anvil build --until ` and `--only ` flags. +- [x] Validate phase names and filter the selected driver's plan before dry-run or execution. +- [x] Cover inclusive prefix selection, single-phase selection, invalid values, and conflicting flags. +- [x] Run `./check`, review the focused diff, and open a pull request into `develop`. + +### Review, Issue 9 + +Phase selection is validated once at the command boundary and feeds both dry-run output and real +execution. Focused tests cover prefixes, single phases, invalid values, conflicts, and optional +signing. `./check`, the binary build, staticcheck, and direct CLI dry runs pass. Pull request #20 +targets `develop`.