From 46416353ebaf475e3867e05d1648d63279d5e56b Mon Sep 17 00:00:00 2001 From: Vinicius Zein Date: Thu, 21 May 2026 11:45:12 -0400 Subject: [PATCH 1/4] feat: skip rebuild when build already succeeded with -d flag When using `bob build -d `, the CLI now checks if the build is already in Succeeded phase before triggering a new run. If so, it downloads existing artifacts directly, showing the commit and toolchain image for transparency. Use --force to override this and always rebuild. This avoids redundant multi-minute builds when the source (commit) and toolchain image haven't changed. Co-authored-by: Cursor --- cmd/bob/build.go | 17 ++++++++++++++--- cmd/bob/wait.go | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/cmd/bob/build.go b/cmd/bob/build.go index 805b355..99c8a12 100644 --- a/cmd/bob/build.go +++ b/cmd/bob/build.go @@ -39,6 +39,7 @@ func newBuildCmd() *cobra.Command { var pvcName string var downloadDir string var skipVerify bool + var force bool cmd := &cobra.Command{ Use: "build [name]", @@ -50,13 +51,15 @@ func newBuildCmd() *cobra.Command { bob build body-ecu-nucleo # re-trigger existing BuildJob bob build body-ecu-mpu-hostlike --local # sync . and build bob build body-ecu-mpu-hostlike --local --source ~/code # sync specific dir - bob build body-ecu-nucleo -d ./out # build and download artifacts + bob build body-ecu-nucleo -d ./out # download artifacts (rebuild only if needed) + bob build body-ecu-nucleo -d ./out --force # force rebuild and download When --local is used, the BuildJob is temporarily switched to use your local source. The next build without --local automatically restores git source. -When -d is used, the CLI waits for the build to complete and automatically -downloads the resulting artifacts to the specified directory.`, +When -d is used, the CLI checks if the build already succeeded. If so, it +downloads the existing artifacts without retriggering. Use --force to rebuild +regardless.`, RunE: func(cmd *cobra.Command, args []string) error { ns := firstNonEmpty(bobNamespace, os.Getenv("BOB_NAMESPACE"), "bob-builds") if local { @@ -99,6 +102,13 @@ downloads the resulting artifacts to the specified directory.`, if err := autoRestoreIfLocal(ns, args[0]); err != nil { return fmt.Errorf("restoring git source: %w", err) } + if downloadDir != "" && !force { + if skipped, err := downloadIfUpToDate(cmd.Context(), args[0], downloadDir, skipVerify); err != nil { + return err + } else if skipped { + return nil + } + } if err := retrigger(cmd.Context(), args[0]); err != nil { return err } @@ -116,6 +126,7 @@ downloads the resulting artifacts to the specified directory.`, cmd.Flags().StringVar(&pvcName, "pvc", "source-code", "PVC name for local source upload") cmd.Flags().StringVarP(&downloadDir, "download", "d", "", "Wait for build to finish and download artifacts to this directory") cmd.Flags().BoolVar(&skipVerify, "skip-verify", false, "Skip cosign signature verification on download") + cmd.Flags().BoolVar(&force, "force", false, "Force a new build even if the last one succeeded") return cmd } diff --git a/cmd/bob/wait.go b/cmd/bob/wait.go index e37d833..4eb952c 100644 --- a/cmd/bob/wait.go +++ b/cmd/bob/wait.go @@ -29,6 +29,32 @@ const ( buildStartupTimeout = 2 * time.Minute ) +// downloadIfUpToDate checks if the build already succeeded and downloads +// artifacts without triggering a new run. Returns (true, nil) if artifacts +// were downloaded, (false, nil) if a new build is needed. +func downloadIfUpToDate(ctx context.Context, name, downloadDir string, skipVerify bool) (bool, error) { + c := newClient() + build, err := c.Get(ctx, name) + if err != nil { + return false, fmt.Errorf("getting build status: %w", err) + } + + if build.Phase != "Succeeded" { + return false, nil + } + + fmt.Printf("Build %q already up-to-date, skipping rebuild.\n", name) + fmt.Printf(" Run: %s\n", build.PipelineRun) + if build.CommitSHA != "" { + fmt.Printf(" Commit: %s\n", build.CommitSHA) + } + if build.Image != "" { + fmt.Printf(" Image: %s\n", build.Image) + } + fmt.Println() + return true, downloadBuildArtifacts(ctx, c, build, downloadDir, skipVerify) +} + func waitAndDownload(ctx context.Context, name, downloadDir string, skipVerify bool) error { c := newClient() From 981d154fd778cd8190fe39fe03e124724d5c3676 Mon Sep 17 00:00:00 2001 From: Vinicius Zein Date: Thu, 21 May 2026 11:46:17 -0400 Subject: [PATCH 2/4] test: add tests for --force flag and startup timeout constants Co-authored-by: Cursor --- cmd/bob/wait_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/bob/wait_test.go b/cmd/bob/wait_test.go index 4fac02d..d1f9286 100644 --- a/cmd/bob/wait_test.go +++ b/cmd/bob/wait_test.go @@ -2,6 +2,7 @@ package main import ( "testing" + "time" ) func TestNewBuildCmd_HasDownloadFlag(t *testing.T) { @@ -21,8 +22,29 @@ func TestNewBuildCmd_HasDownloadFlag(t *testing.T) { } } +func TestNewBuildCmd_HasForceFlag(t *testing.T) { + cmd := newBuildCmd() + + f := cmd.Flags().Lookup("force") + if f == nil { + t.Fatal("expected --force flag") + } + if f.DefValue != "false" { + t.Errorf("expected default false, got %s", f.DefValue) + } +} + func TestBuildPollInterval(t *testing.T) { if buildPollInterval <= 0 { t.Fatal("poll interval should be positive") } } + +func TestBuildStartupTimeout(t *testing.T) { + if buildStartupTimeout < 1*time.Minute { + t.Fatal("startup timeout should be at least 1 minute") + } + if buildStartupTimeout > 5*time.Minute { + t.Fatal("startup timeout should not exceed 5 minutes") + } +} From 4bb83da0b61cb04a2ba5a7a6079af298aef5cdfb Mon Sep 17 00:00:00 2001 From: Vinicius Zein Date: Thu, 21 May 2026 11:49:53 -0400 Subject: [PATCH 3/4] fix: extract phase string literals into constants (goconst) Co-authored-by: Cursor --- cmd/bob/wait.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/bob/wait.go b/cmd/bob/wait.go index 4eb952c..3367cd8 100644 --- a/cmd/bob/wait.go +++ b/cmd/bob/wait.go @@ -27,6 +27,11 @@ import ( const ( buildPollInterval = 5 * time.Second buildStartupTimeout = 2 * time.Minute + + phaseSucceeded = "Succeeded" + phaseFailed = "Failed" + phaseRunning = "Running" + phasePending = "Pending" ) // downloadIfUpToDate checks if the build already succeeded and downloads @@ -39,7 +44,7 @@ func downloadIfUpToDate(ctx context.Context, name, downloadDir string, skipVerif return false, fmt.Errorf("getting build status: %w", err) } - if build.Phase != "Succeeded" { + if build.Phase != phaseSucceeded { return false, nil } @@ -86,10 +91,10 @@ func waitAndDownload(ctx context.Context, name, downloadDir string, skipVerify b case build.PipelineRun != previousRun: buildStarted = true fmt.Printf(" New PipelineRun: %s\n", build.PipelineRun) - case build.Phase == "Running" || build.Phase == "Pending": + case build.Phase == phaseRunning || build.Phase == phasePending: buildStarted = true fmt.Printf(" PipelineRun: %s\n", build.PipelineRun) - case build.Phase == "Succeeded" || build.Phase == "Failed": + case build.Phase == phaseSucceeded || build.Phase == phaseFailed: if time.Since(start) > buildStartupTimeout { fmt.Printf(" Build already completed (run %s), downloading existing artifacts.\n", build.PipelineRun) buildStarted = true @@ -112,10 +117,10 @@ func waitAndDownload(ctx context.Context, name, downloadDir string, skipVerify b } switch build.Phase { - case "Succeeded": + case phaseSucceeded: fmt.Printf("\nBuild succeeded in %s\n", time.Since(start).Truncate(time.Second)) return downloadBuildArtifacts(ctx, c, build, downloadDir, skipVerify) - case "Failed": + case phaseFailed: fmt.Fprintf(os.Stderr, "\nBuild failed after %s\n", time.Since(start).Truncate(time.Second)) fmt.Fprintf(os.Stderr, "View logs with: bob logs %s\n", name) return fmt.Errorf("build %q failed", name) From 2ff8f96f770c2b96882f7ab7e916ad57d96dd25d Mon Sep 17 00:00:00 2001 From: Vinicius Zein Date: Thu, 21 May 2026 11:53:15 -0400 Subject: [PATCH 4/4] fix: always rebuild after restoring from local source When autoRestoreIfLocal switches the BuildJob back to git source, the previous Succeeded phase is from the local build. Skip the up-to-date check in that case to ensure a fresh build from the restored git source. Co-authored-by: Cursor --- cmd/bob/build.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cmd/bob/build.go b/cmd/bob/build.go index 99c8a12..ffc400a 100644 --- a/cmd/bob/build.go +++ b/cmd/bob/build.go @@ -99,10 +99,11 @@ regardless.`, if len(args) == 0 { return fmt.Errorf("provide a BuildJob name or use -f ") } - if err := autoRestoreIfLocal(ns, args[0]); err != nil { + restored, err := autoRestoreIfLocal(ns, args[0]) + if err != nil { return fmt.Errorf("restoring git source: %w", err) } - if downloadDir != "" && !force { + if downloadDir != "" && !force && !restored { if skipped, err := downloadIfUpToDate(cmd.Context(), args[0], downloadDir, skipVerify); err != nil { return err } else if skipped { @@ -194,17 +195,17 @@ func createFromFile(ctx context.Context, path string, branch string) (string, er return result.Name, nil } -func autoRestoreIfLocal(namespace, bjName string) error { +func autoRestoreIfLocal(namespace, bjName string) (restored bool, err error) { kubecli := detectKubeClient() if kubecli == "" { - return nil + return false, nil } out, _ := exec.Command(kubecli, "get", "buildjob", bjName, "-n", namespace, "-o", `jsonpath={.metadata.annotations.builder\.sdv\.cloud\.redhat\.com/original-source}`).CombinedOutput() if len(out) > 0 && string(out) != "" { - return restoreGitSource(kubecli, namespace, bjName) + return true, restoreGitSource(kubecli, namespace, bjName) } - return nil + return false, nil } func retrigger(ctx context.Context, name string) error {