Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions cmd/wfctl/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"os"
"os/exec"
"strings"

"github.com/GoCodeAlone/workflow/config"
Expand Down Expand Up @@ -40,14 +41,15 @@ func runBuild(args []string) error {
fs := flag.NewFlagSet("build", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
cfgPath string
dryRun bool
only string
skip string
tag string
format string
noPush bool
envName string
cfgPath string
dryRun bool
only string
skip string
tag string
format string
noPush bool
envName string
fallbackGoBuild bool
)
fs.StringVar(&cfgPath, "config", "workflow.yaml", "Path to workflow config file")
fs.StringVar(&cfgPath, "c", "workflow.yaml", "Path to workflow config file (short)")
Expand All @@ -57,6 +59,7 @@ func runBuild(args []string) error {
fs.StringVar(&tag, "tag", "", "Override image tag for all container targets")
fs.StringVar(&format, "format", "table", "Output format: table | json | yaml")
fs.BoolVar(&noPush, "no-push", false, "Build but do not push images to registries")
fs.BoolVar(&fallbackGoBuild, "fallback-go-build", false, "Run go build ./... when ci.build has no build targets")
var push bool
fs.BoolVar(&push, "push", true, "Push images to registries after build (default true; --push=false is equivalent to --no-push)")
fs.StringVar(&envName, "env", "", "Environment name for per-env config overrides")
Expand All @@ -77,7 +80,10 @@ func runBuild(args []string) error {
if err != nil {
return fmt.Errorf("wfctl build: load config: %w", err)
}
if cfg.CI == nil || cfg.CI.Build == nil {
if cfg.CI == nil || cfg.CI.Build == nil || !hasConfiguredBuildTargets(cfg.CI.Build) {
if fallbackGoBuild {
return runDefaultGoBuild(dryRun)
}
fmt.Println("No build configuration, skipping build phase")
return nil
}
Expand All @@ -94,6 +100,25 @@ func runBuild(args []string) error {
})
}

func hasConfiguredBuildTargets(build *config.CIBuildConfig) bool {
if build == nil {
return false
}
return len(build.Targets) > 0 || len(build.Containers) > 0 || len(build.Assets) > 0
}

func runDefaultGoBuild(dryRun bool) error {
if dryRun {
fmt.Println("[dry-run] go build ./...")
return nil
}
fmt.Println("No build targets configured, running go build ./...")
cmd := exec.Command("go", "build", "./...")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// buildOpts carries parsed build flags for use across subcommands.
type buildOpts struct {
dryRun bool
Expand Down Expand Up @@ -168,6 +193,12 @@ func runBuildOrchestrate(cfg *config.WorkflowConfig, opts buildOpts) error {
if opts.tag != "" {
imgArgs = append(imgArgs, "--tag", opts.tag)
}
if len(opts.only) > 0 {
imgArgs = append(imgArgs, "--only", strings.Join(opts.only, ","))
}
if len(opts.skip) > 0 {
imgArgs = append(imgArgs, "--skip", strings.Join(opts.skip, ","))
}
// For hardened builds (docker buildx with docker-container driver), pass
// --push so buildx pushes directly from the buildkit cache. Without this,
// buildx would silently cache the result and the subsequent docker push
Expand All @@ -190,6 +221,12 @@ func runBuildOrchestrate(cfg *config.WorkflowConfig, opts buildOpts) error {
if opts.tag != "" {
pushArgs = append(pushArgs, "--tag", opts.tag)
}
if len(opts.only) > 0 {
pushArgs = append(pushArgs, "--only", strings.Join(opts.only, ","))
}
if len(opts.skip) > 0 {
pushArgs = append(pushArgs, "--skip", strings.Join(opts.skip, ","))
}
if err := runBuildPush(pushArgs); err != nil {
return fmt.Errorf("push: %w", err)
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/wfctl/build_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ func runBuildImageWithOutput(args []string, out io.Writer) error {
cfgPath := fs.String("config", "", "Config file")
dryRun := fs.Bool("dry-run", false, "Print planned actions without executing")
tagOverride := fs.String("tag", "", "Override image tag for all containers")
only := fs.String("only", "", "Build only containers matching this name (comma-separated)")
skip := fs.String("skip", "", "Skip containers matching this name (comma-separated)")
pushImages := fs.Bool("push", false, "Push images directly via buildx (hardened mode: adds --push to buildx; non-hardened: no effect, separate push step handles it)")
if err := fs.Parse(args); err != nil {
return err
}
filter := buildOpts{only: splitCSV(*only), skip: splitCSV(*skip)}

if os.Getenv("WFCTL_BUILD_DRY_RUN") == "1" {
*dryRun = true
Expand Down Expand Up @@ -57,6 +60,9 @@ func runBuildImageWithOutput(args []string, out io.Writer) error {

for i := range cfg.CI.Build.Containers {
ctr := cfg.CI.Build.Containers[i]
if !shouldInclude(ctr.Name, filter) {
continue
}
tag := *tagOverride
if tag == "" {
tag = "latest"
Expand Down
59 changes: 59 additions & 0 deletions cmd/wfctl/build_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,65 @@ func TestRunBuildImage_DockerfileDryRun(t *testing.T) {
}
}

func TestRunBuildImage_OnlySkipsUnmatchedContainers(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
build:
containers:
- name: app
method: dockerfile
dockerfile: Dockerfile
- name: worker
method: dockerfile
dockerfile: Dockerfile.worker
`
cfgPath := filepath.Join(dir, "ci.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("WFCTL_BUILD_DRY_RUN", "1")

var buf bytes.Buffer
if err := runBuildImageWithOutput([]string{"--config", cfgPath, "--only", "worker"}, &buf); err != nil {
t.Fatalf("dockerfile dry-run: %v", err)
}
out := buf.String()
if strings.Contains(out, " app:") || strings.Contains(out, "Dockerfile ") {
t.Fatalf("--only worker should skip app container, output:\n%s", out)
}
if !strings.Contains(out, "Dockerfile.worker") {
t.Fatalf("--only worker should include worker container, output:\n%s", out)
}
}

func TestRunBuildImage_SkipCSVSkipsMatchedContainers(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
build:
containers:
- name: app
method: dockerfile
dockerfile: Dockerfile
- name: worker
method: dockerfile
dockerfile: Dockerfile.worker
`
cfgPath := filepath.Join(dir, "ci.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("WFCTL_BUILD_DRY_RUN", "1")

var buf bytes.Buffer
if err := runBuildImageWithOutput([]string{"--config", cfgPath, "--skip", "app,worker"}, &buf); err != nil {
t.Fatalf("dockerfile dry-run: %v", err)
}
out := buf.String()
if strings.Contains(out, "docker build") || strings.Contains(out, "Dockerfile") {
t.Fatalf("--skip app,worker should skip all container builds, output:\n%s", out)
}
}

func TestRunBuildImage_KoDryRun(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
Expand Down
126 changes: 115 additions & 11 deletions cmd/wfctl/build_orchestrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,109 @@ func TestRunBuild_OrchestratorHonorsOnly(t *testing.T) {
}
}

func TestRunBuild_OrchestratorOnlySkipsContainers(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
build:
targets:
- name: server
type: go
path: ./cmd/server
containers:
- name: app
method: dockerfile
dockerfile: Dockerfile
`
cfgPath := filepath.Join(dir, "ci.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}

t.Setenv("WFCTL_BUILD_DRY_RUN", "1")
out, err := captureStdout(t, func() error {
return runBuild([]string{"--config", cfgPath, "--dry-run", "--only", "server"})
})
if err != nil {
t.Fatalf("--only dry-run: %v", err)
}
if strings.Contains(out, "docker build") || strings.Contains(out, "docker buildx") {
t.Fatalf("--only server should skip container target, output:\n%s", out)
}
}

func TestRunBuild_OrchestratorPreservesCommaSeparatedContainerFilters(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
build:
targets:
- name: server
type: go
path: ./cmd/server
containers:
- name: app
method: dockerfile
dockerfile: Dockerfile.app
- name: worker
method: dockerfile
dockerfile: Dockerfile.worker
`
cfgPath := filepath.Join(dir, "ci.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}

t.Setenv("WFCTL_BUILD_DRY_RUN", "1")
out, err := captureStdout(t, func() error {
return runBuild([]string{"--config", cfgPath, "--dry-run", "--only", "server,app,worker"})
})
if err != nil {
t.Fatalf("--only dry-run: %v", err)
}
for _, want := range []string{"Dockerfile.app", "Dockerfile.worker"} {
if !strings.Contains(out, want) {
t.Fatalf("--only should preserve all container filters; missing %s in output:\n%s", want, out)
}
}
}

func TestRunBuild_OrchestratorOnlyFiltersPushPhase(t *testing.T) {
dir := t.TempDir()
cfg := `ci:
registries:
- name: docr
type: do
path: registry.example.com/acme
build:
containers:
- name: app
method: dockerfile
dockerfile: Dockerfile.app
push_to: [docr]
- name: worker
method: dockerfile
dockerfile: Dockerfile.worker
push_to: [docr]
`
cfgPath := filepath.Join(dir, "ci.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}

t.Setenv("WFCTL_BUILD_DRY_RUN", "1")
out, err := captureStdout(t, func() error {
return runBuild([]string{"--config", cfgPath, "--only", "worker"})
})
if err != nil {
t.Fatalf("--only dry-run via env: %v", err)
}
if strings.Contains(out, "/app:") || strings.Contains(out, "Dockerfile.app") {
t.Fatalf("--only worker should skip app build and push phases, output:\n%s", out)
}
if !strings.Contains(out, "/worker:") {
t.Fatalf("--only worker should include worker push phase, output:\n%s", out)
}
}

func TestRunBuild_OrchestratorHonorsSkip(t *testing.T) {
opts := buildOpts{skip: []string{"flaky"}}
if shouldInclude("flaky", opts) {
Expand Down Expand Up @@ -138,7 +241,7 @@ func TestRunBuild_PushFlagDefined(t *testing.T) {
// TestRunBuild_FlagsRegistered documents the expected flag surface. It relies on the
// fake map only as documentation; TestRunBuild_PushFlagDefined provides the real gate.
func TestRunBuild_FlagsRegistered(t *testing.T) {
required := []string{"config", "dry-run", "only", "skip", "tag", "format", "no-push", "push", "env"}
required := []string{"config", "dry-run", "only", "skip", "tag", "format", "no-push", "push", "env", "fallback-go-build"}
registered := buildFlagNames()
for _, name := range required {
if !registered[name] {
Expand All @@ -149,16 +252,17 @@ func TestRunBuild_FlagsRegistered(t *testing.T) {

func buildFlagNames() map[string]bool {
return map[string]bool{
"config": true,
"c": true,
"dry-run": true,
"only": true,
"skip": true,
"tag": true,
"format": true,
"no-push": true,
"push": true,
"env": true,
"config": true,
"c": true,
"dry-run": true,
"only": true,
"skip": true,
"tag": true,
"format": true,
"no-push": true,
"push": true,
"env": true,
"fallback-go-build": true,
}
}

Expand Down
6 changes: 6 additions & 0 deletions cmd/wfctl/build_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ func runBuildPush(args []string) error {
fs := flag.NewFlagSet("build push", flag.ContinueOnError)
cfgPath := fs.String("config", "workflow.yaml", "Path to workflow config file")
tagOverride := fs.String("tag", "", "Override image tag for all containers")
only := fs.String("only", "", "Push only containers matching this name (comma-separated)")
skip := fs.String("skip", "", "Skip containers matching this name (comma-separated)")
if err := fs.Parse(args); err != nil {
return err
}
filter := buildOpts{only: splitCSV(*only), skip: splitCSV(*skip)}

cfg, err := config.LoadFromFile(*cfgPath)
if err != nil {
Expand All @@ -37,6 +40,9 @@ func runBuildPush(args []string) error {

for i := range cfg.CI.Build.Containers {
ct := cfg.CI.Build.Containers[i]
if !shouldInclude(ct.Name, filter) {
continue
}
if ct.External {
continue
}
Expand Down
Loading
Loading