From 512aee9cc2c1c7d354eea6da6ffd61f63edecc98 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 17:11:26 -0400 Subject: [PATCH 1/6] ci: generate wfctl-centered build jobs Use wfctl ci run and wfctl build in generated CI instead of raw go test/go build commands. Antagonistic review removed default publish behavior to keep generated jobs validation-only. --- cmd/wfctl/ci.go | 13 ++++++++----- cmd/wfctl/ci_test.go | 11 +++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/cmd/wfctl/ci.go b/cmd/wfctl/ci.go index 433d9c51..2de9d251 100644 --- a/cmd/wfctl/ci.go +++ b/cmd/wfctl/ci.go @@ -280,10 +280,12 @@ jobs: with: go-version-file: go.mod cache: true + - name: Install wfctl + run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - name: Run tests - run: go test ./... - - name: Build - run: go build ./... + run: wfctl ci run --config '{{.InfraConfig}}' --phase test + - name: Build without push + run: wfctl build --config '{{.InfraConfig}}' --no-push --tag ci ` // ── GitLab CI ───────────────────────────────────────────────────────────────── @@ -352,8 +354,9 @@ build: stage: build needs: [] script: - - go test ./... - - go build ./... + - go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest + - wfctl ci run --config "$INFRA_CONFIG" --phase test + - wfctl build --config "$INFRA_CONFIG" --no-push --tag ci rules: - if: $CI_COMMIT_BRANCH == "{{.Branch}}" - if: $CI_PIPELINE_SOURCE == "merge_request_event" diff --git a/cmd/wfctl/ci_test.go b/cmd/wfctl/ci_test.go index 81b331c3..dcebe177 100644 --- a/cmd/wfctl/ci_test.go +++ b/cmd/wfctl/ci_test.go @@ -47,6 +47,15 @@ func TestGenerateGitHubActions(t *testing.T) { if !strings.Contains(buildYML, "actions/setup-go@v5") { t.Error("build.yml missing actions/setup-go@v5") } + if !strings.Contains(buildYML, "wfctl ci run --config 'infra.yaml' --phase test") { + t.Error("build.yml missing wfctl ci run test phase") + } + if !strings.Contains(buildYML, "wfctl build --config 'infra.yaml' --no-push --tag ci") { + t.Error("build.yml missing wfctl build") + } + if strings.Contains(buildYML, "go build ./...") { + t.Error("build.yml should use wfctl build instead of raw go build") + } } func TestGenerateGitLabCI(t *testing.T) { @@ -69,6 +78,8 @@ func TestGenerateGitLabCI(t *testing.T) { "rules:", "needs:", "wfctl infra plan", + "wfctl ci run --config \"$INFRA_CONFIG\" --phase test", + "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci", "environment:", } for _, m := range markers { From 726a0a6e2dbb0208eae1a7ebef60654053759de7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 17:29:04 -0400 Subject: [PATCH 2/6] fix(wfctl): harden generated CI jobs --- cmd/wfctl/ci.go | 27 +++++++++++++++++++-------- cmd/wfctl/ci_test.go | 13 +++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/cmd/wfctl/ci.go b/cmd/wfctl/ci.go index 2de9d251..abdd022e 100644 --- a/cmd/wfctl/ci.go +++ b/cmd/wfctl/ci.go @@ -230,7 +230,7 @@ jobs: go-version-file: go.mod cache: true - name: Install wfctl - run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest + uses: GoCodeAlone/setup-wfctl@v1 - name: Plan infrastructure run: wfctl infra plan --config '{{.InfraConfig}}' --format markdown > plan.md - name: Post plan comment @@ -255,7 +255,7 @@ jobs: go-version-file: go.mod cache: true - name: Install wfctl - run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest + uses: GoCodeAlone/setup-wfctl@v1 - name: Apply infrastructure run: wfctl infra apply --config '{{.InfraConfig}}' --auto-approve ` @@ -281,9 +281,14 @@ jobs: go-version-file: go.mod cache: true - name: Install wfctl - run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest + uses: GoCodeAlone/setup-wfctl@v1 - name: Run tests - run: wfctl ci run --config '{{.InfraConfig}}' --phase test + run: | + if awk '/^[^[:space:]#][^:]*:/ { if ($0 ~ /^ci:/) found=1 } END { exit found ? 0 : 1 }' '{{.InfraConfig}}'; then + wfctl ci run --config '{{.InfraConfig}}' --phase test + else + go test ./... + fi - name: Build without push run: wfctl build --config '{{.InfraConfig}}' --no-push --tag ci ` @@ -319,10 +324,13 @@ const gitlabCITemplate = `stages: variables: INFRA_CONFIG: "{{.InfraConfig}}" +before_script: + - go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest + - export PATH="$(go env GOPATH)/bin:$PATH" + infra-plan: stage: plan script: - - go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - wfctl infra plan --config "$INFRA_CONFIG" --format markdown > plan.md artifacts: paths: @@ -340,7 +348,6 @@ infra-apply: - job: infra-plan artifacts: true script: - - go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - wfctl infra apply --config "$INFRA_CONFIG" --auto-approve environment: name: production @@ -354,8 +361,12 @@ build: stage: build needs: [] script: - - go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - - wfctl ci run --config "$INFRA_CONFIG" --phase test + - | + if awk '/^[^[:space:]#][^:]*:/ { if ($0 ~ /^ci:/) found=1 } END { exit found ? 0 : 1 }' "$INFRA_CONFIG"; then + wfctl ci run --config "$INFRA_CONFIG" --phase test + else + go test ./... + fi - wfctl build --config "$INFRA_CONFIG" --no-push --tag ci rules: - if: $CI_COMMIT_BRANCH == "{{.Branch}}" diff --git a/cmd/wfctl/ci_test.go b/cmd/wfctl/ci_test.go index dcebe177..fda13c07 100644 --- a/cmd/wfctl/ci_test.go +++ b/cmd/wfctl/ci_test.go @@ -27,6 +27,7 @@ func TestGenerateGitHubActions(t *testing.T) { markers := []string{ "actions/checkout@v4", "actions/setup-go@v5", + "GoCodeAlone/setup-wfctl@v1", "wfctl infra plan", "permissions", "actions/github-script@v7", @@ -47,9 +48,18 @@ func TestGenerateGitHubActions(t *testing.T) { if !strings.Contains(buildYML, "actions/setup-go@v5") { t.Error("build.yml missing actions/setup-go@v5") } + if !strings.Contains(buildYML, "GoCodeAlone/setup-wfctl@v1") { + t.Error("build.yml missing setup-wfctl action") + } + if !strings.Contains(buildYML, "awk '/^[^[:space:]#][^:]*:/") { + t.Error("build.yml missing top-level ci section check") + } if !strings.Contains(buildYML, "wfctl ci run --config 'infra.yaml' --phase test") { t.Error("build.yml missing wfctl ci run test phase") } + if !strings.Contains(buildYML, "go test ./...") { + t.Error("build.yml missing fallback go test command") + } if !strings.Contains(buildYML, "wfctl build --config 'infra.yaml' --no-push --tag ci") { t.Error("build.yml missing wfctl build") } @@ -77,8 +87,11 @@ func TestGenerateGitLabCI(t *testing.T) { markers := []string{ "rules:", "needs:", + "before_script:", + "export PATH=\"$(go env GOPATH)/bin:$PATH\"", "wfctl infra plan", "wfctl ci run --config \"$INFRA_CONFIG\" --phase test", + "go test ./...", "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci", "environment:", } From 5fb84256cbe0850994b5c0c61f30ee160e8a9238 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 17:54:11 -0400 Subject: [PATCH 3/6] fix(wfctl): filter image and push targets --- cmd/wfctl/build.go | 12 +++ cmd/wfctl/build_image.go | 6 ++ cmd/wfctl/build_image_test.go | 59 +++++++++++ cmd/wfctl/build_orchestrate_test.go | 103 +++++++++++++++++++ cmd/wfctl/build_push.go | 6 ++ cmd/wfctl/build_push_test.go | 35 +++++++ docs/manual/build-deploy/05-cli-reference.md | 10 +- 7 files changed, 227 insertions(+), 4 deletions(-) diff --git a/cmd/wfctl/build.go b/cmd/wfctl/build.go index 95e94367..083444dc 100644 --- a/cmd/wfctl/build.go +++ b/cmd/wfctl/build.go @@ -168,6 +168,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 @@ -190,6 +196,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) } diff --git a/cmd/wfctl/build_image.go b/cmd/wfctl/build_image.go index b6d7a30f..74cd9ed4 100644 --- a/cmd/wfctl/build_image.go +++ b/cmd/wfctl/build_image.go @@ -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 @@ -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" diff --git a/cmd/wfctl/build_image_test.go b/cmd/wfctl/build_image_test.go index 9166e41b..4c1ff106 100644 --- a/cmd/wfctl/build_image_test.go +++ b/cmd/wfctl/build_image_test.go @@ -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: diff --git a/cmd/wfctl/build_orchestrate_test.go b/cmd/wfctl/build_orchestrate_test.go index 8765bec2..2fe03eae 100644 --- a/cmd/wfctl/build_orchestrate_test.go +++ b/cmd/wfctl/build_orchestrate_test.go @@ -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) { diff --git a/cmd/wfctl/build_push.go b/cmd/wfctl/build_push.go index 70fe7aad..62df82a3 100644 --- a/cmd/wfctl/build_push.go +++ b/cmd/wfctl/build_push.go @@ -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 { @@ -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 } diff --git a/cmd/wfctl/build_push_test.go b/cmd/wfctl/build_push_test.go index d6093422..71c21efd 100644 --- a/cmd/wfctl/build_push_test.go +++ b/cmd/wfctl/build_push_test.go @@ -98,6 +98,41 @@ ci: } } +func TestRunBuildPush_OnlySkipsUnmatchedContainers(t *testing.T) { + dir := t.TempDir() + cfgPath := writeBuildPushFixture(t, dir) + + t.Setenv("WFCTL_BUILD_DRY_RUN", "1") + out, err := captureStdout(t, func() error { + return runBuildPush([]string{"--config", cfgPath, "--only", "worker"}) + }) + if err != nil { + t.Fatalf("runBuildPush --only dry-run: %v", err) + } + if strings.Contains(out, "/api:") { + t.Fatalf("--only worker should skip api pushes, output:\n%s", out) + } + if !strings.Contains(out, "/worker:") { + t.Fatalf("--only worker should include worker push, output:\n%s", out) + } +} + +func TestRunBuildPush_SkipCSVSkipsMatchedContainers(t *testing.T) { + dir := t.TempDir() + cfgPath := writeBuildPushFixture(t, dir) + + t.Setenv("WFCTL_BUILD_DRY_RUN", "1") + out, err := captureStdout(t, func() error { + return runBuildPush([]string{"--config", cfgPath, "--skip", "api,worker"}) + }) + if err != nil { + t.Fatalf("runBuildPush --skip dry-run: %v", err) + } + if strings.Contains(out, "push ") { + t.Fatalf("--skip api,worker should skip all pushes, output:\n%s", out) + } +} + func TestRunBuildPush_UnknownRegistry(t *testing.T) { dir := t.TempDir() content := ` diff --git a/docs/manual/build-deploy/05-cli-reference.md b/docs/manual/build-deploy/05-cli-reference.md index fbae44f0..29c88826 100644 --- a/docs/manual/build-deploy/05-cli-reference.md +++ b/docs/manual/build-deploy/05-cli-reference.md @@ -72,10 +72,11 @@ Wraps the `nodejs` builder plugin. ## `wfctl build image` ``` -wfctl build image [--config ] [--dry-run] [--tag ] [--push] +wfctl build image [--config ] [--dry-run] [--tag ] [--only ] [--skip ] [--push] ``` -Builds all `ci.build.containers[]` entries. External containers are resolved (not built). +Builds matching `ci.build.containers[]` entries. External containers are resolved (not built). +`--only` and `--skip` accept comma-separated container names. When `--push` is passed and `ci.build.security.hardened: true`, buildx pushes directly to every registry in `push_to[]` via multiple `--tag` flags in a single invocation (no separate @@ -88,10 +89,11 @@ the buildkit cache. ## `wfctl build push` ``` -wfctl build push [--config ] +wfctl build push [--config ] [--tag ] [--only ] [--skip ] ``` -Pushes each container's image to every registry listed in `push_to[]`. +Pushes each matching container's image to every registry listed in `push_to[]`. +`--only` and `--skip` accept comma-separated container names. --- From 3ec6c8d85a26c25b78cfb935e9fcff84e344409d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 18:44:43 -0400 Subject: [PATCH 4/6] fix(wfctl): harden generated CI tests --- cmd/wfctl/ci.go | 63 ++++++---- cmd/wfctl/ci_run.go | 72 ++++++++---- cmd/wfctl/ci_run_test.go | 96 ++++++++++++++++ cmd/wfctl/ci_test.go | 60 ++++++++-- config/ci_build_security.go | 21 +++- config/config.go | 216 +++++++++++++++++++++++++++++++++++ config/config_import_test.go | 182 +++++++++++++++++++++++++++++ 7 files changed, 654 insertions(+), 56 deletions(-) diff --git a/cmd/wfctl/ci.go b/cmd/wfctl/ci.go index abdd022e..35560060 100644 --- a/cmd/wfctl/ci.go +++ b/cmd/wfctl/ci.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "text/template" @@ -176,16 +177,18 @@ func generateCIFiles(opts ciOptions) (map[string]string, error) { // ── GitHub Actions ──────────────────────────────────────────────────────────── type ghaTemplateData struct { - InfraConfig string - Runner string - Branch string + InfraConfig string + Runner string + Branch string + WfctlVersion string } func generateGitHubActions(opts ciOptions) (map[string]string, error) { data := ghaTemplateData{ - InfraConfig: opts.InfraConfig, - Runner: opts.Runner, - Branch: "main", + InfraConfig: opts.InfraConfig, + Runner: opts.Runner, + Branch: "main", + WfctlVersion: ciGeneratedWfctlVersion(), } infraYAML, err := renderCITemplate("gha-infra", ghaInfraTemplate, data) @@ -231,6 +234,8 @@ jobs: cache: true - name: Install wfctl uses: GoCodeAlone/setup-wfctl@v1 + with: + version: '{{.WfctlVersion}}' - name: Plan infrastructure run: wfctl infra plan --config '{{.InfraConfig}}' --format markdown > plan.md - name: Post plan comment @@ -256,6 +261,8 @@ jobs: cache: true - name: Install wfctl uses: GoCodeAlone/setup-wfctl@v1 + with: + version: '{{.WfctlVersion}}' - name: Apply infrastructure run: wfctl infra apply --config '{{.InfraConfig}}' --auto-approve ` @@ -282,28 +289,31 @@ jobs: cache: true - name: Install wfctl uses: GoCodeAlone/setup-wfctl@v1 + with: + version: '{{.WfctlVersion}}' - name: Run tests - run: | - if awk '/^[^[:space:]#][^:]*:/ { if ($0 ~ /^ci:/) found=1 } END { exit found ? 0 : 1 }' '{{.InfraConfig}}'; then - wfctl ci run --config '{{.InfraConfig}}' --phase test - else - go test ./... - fi + env: + INFRA_CONFIG: '{{.InfraConfig}}' + run: wfctl ci run --config "$INFRA_CONFIG" --phase test - name: Build without push - run: wfctl build --config '{{.InfraConfig}}' --no-push --tag ci + env: + INFRA_CONFIG: '{{.InfraConfig}}' + run: wfctl build --config "$INFRA_CONFIG" --no-push --tag ci ` // ── GitLab CI ───────────────────────────────────────────────────────────────── type gitlabTemplateData struct { - InfraConfig string - Branch string + InfraConfig string + Branch string + WfctlVersion string } func generateGitLabCI(opts ciOptions) (map[string]string, error) { data := gitlabTemplateData{ - InfraConfig: opts.InfraConfig, - Branch: "main", + InfraConfig: opts.InfraConfig, + Branch: "main", + WfctlVersion: ciGeneratedWfctlVersion(), } content, err := renderCITemplate("gitlab-ci", gitlabCITemplate, data) @@ -323,9 +333,10 @@ const gitlabCITemplate = `stages: variables: INFRA_CONFIG: "{{.InfraConfig}}" + WFCTL_VERSION: "{{.WfctlVersion}}" before_script: - - go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest + - go install "github.com/GoCodeAlone/workflow/cmd/wfctl@${WFCTL_VERSION}" - export PATH="$(go env GOPATH)/bin:$PATH" infra-plan: @@ -361,12 +372,7 @@ build: stage: build needs: [] script: - - | - if awk '/^[^[:space:]#][^:]*:/ { if ($0 ~ /^ci:/) found=1 } END { exit found ? 0 : 1 }' "$INFRA_CONFIG"; then - wfctl ci run --config "$INFRA_CONFIG" --phase test - else - go test ./... - fi + - wfctl ci run --config "$INFRA_CONFIG" --phase test - wfctl build --config "$INFRA_CONFIG" --no-push --tag ci rules: - if: $CI_COMMIT_BRANCH == "{{.Branch}}" @@ -385,3 +391,12 @@ func renderCITemplate(name, tmplStr string, data any) (string, error) { } return buf.String(), nil } + +var cleanReleaseTagPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) + +func ciGeneratedWfctlVersion() string { + if !cleanReleaseTagPattern.MatchString(version) { + return "latest" + } + return version +} diff --git a/cmd/wfctl/ci_run.go b/cmd/wfctl/ci_run.go index 56ddfea1..5dec18e7 100644 --- a/cmd/wfctl/ci_run.go +++ b/cmd/wfctl/ci_run.go @@ -13,7 +13,6 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" - "gopkg.in/yaml.v3" ) func runCIRun(args []string) error { @@ -33,16 +32,9 @@ func runCIRun(args []string) error { return err } - data, err := os.ReadFile(*configFile) + cfg, err := config.LoadFromFile(*configFile) if err != nil { - return fmt.Errorf("read config: %w", err) - } - var cfg config.WorkflowConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - return fmt.Errorf("parse config: %w", err) - } - if cfg.CI == nil { - return fmt.Errorf("no ci: section in %s", *configFile) + return fmt.Errorf("load config: %w", err) } phaseList := strings.Split(*phases, ",") @@ -54,11 +46,17 @@ func runCIRun(args []string) error { return fmt.Errorf("build phase failed: %w", err) } } - if err := runBuildPhase(cfg.CI.Build, *verbose); err != nil { + if err := runBuildPhase(ciBuild(cfg), *verbose); err != nil { return fmt.Errorf("build phase failed: %w", err) } case "test": - if err := runTestPhase(cfg.CI.Test, *verbose); err != nil { + if !hasConfiguredTests(ciTest(cfg)) { + if err := runDefaultGoTests(*verbose); err != nil { + return fmt.Errorf("test phase failed: %w", err) + } + continue + } + if err := runTestPhase(ciTest(cfg), *verbose); err != nil { return fmt.Errorf("test phase failed: %w", err) } case "deploy": @@ -66,26 +64,20 @@ func runCIRun(args []string) error { return fmt.Errorf("--env is required for deploy phase") } if *dryRun { - // Load via config.LoadFromFile to honor top-level imports: - // sections that yaml.Unmarshal above would not resolve. - fullCfg, loadErr := config.LoadFromFile(*configFile) - if loadErr != nil { - return fmt.Errorf("load config for dry-run: %w", loadErr) - } - return runDeployPhaseDryRun(fullCfg.CI.Deploy, *env, fullCfg, fullCfg.Services, *dryRunFormat, *configFile) + return runDeployPhaseDryRun(ciDeploy(cfg), *env, cfg, cfg.Services, *dryRunFormat, *configFile) } if *pluginDir != "" { os.Setenv("WFCTL_PLUGIN_DIR", *pluginDir) //nolint:errcheck } - if err := runMigrationDeployGuard(*configFile, *env, *pluginDir, &cfg); err != nil { + if err := runMigrationDeployGuard(*configFile, *env, *pluginDir, cfg); err != nil { return fmt.Errorf("migration guard failed: %w", err) } if len(cfg.Services) > 0 { - if err := runMultiServiceDeploy(cfg.CI.Deploy, *env, &cfg, cfg.Services, *verbose); err != nil { + if err := runMultiServiceDeploy(ciDeploy(cfg), *env, cfg, cfg.Services, *verbose); err != nil { return fmt.Errorf("deploy phase failed: %w", err) } } else { - if err := runDeployPhaseWithConfig(cfg.CI.Deploy, *env, &cfg, nil, *verbose); err != nil { + if err := runDeployPhaseWithConfig(ciDeploy(cfg), *env, cfg, nil, *verbose); err != nil { return fmt.Errorf("deploy phase failed: %w", err) } } @@ -96,6 +88,42 @@ func runCIRun(args []string) error { return nil } +func ciBuild(cfg *config.WorkflowConfig) *config.CIBuildConfig { + if cfg == nil || cfg.CI == nil { + return nil + } + return cfg.CI.Build +} + +func ciTest(cfg *config.WorkflowConfig) *config.CITestConfig { + if cfg == nil || cfg.CI == nil { + return nil + } + return cfg.CI.Test +} + +func ciDeploy(cfg *config.WorkflowConfig) *config.CIDeployConfig { + if cfg == nil || cfg.CI == nil { + return nil + } + return cfg.CI.Deploy +} + +func hasConfiguredTests(test *config.CITestConfig) bool { + if test == nil { + return false + } + return test.Unit != nil || test.Integration != nil || test.E2E != nil +} + +func runDefaultGoTests(_ bool) error { + fmt.Println("No test configuration, running go test ./...") + cmd := exec.Command("go", "test", "./...") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + func runMigrationDeployGuard(configFile, envName, pluginDir string, cfg *config.WorkflowConfig) error { if cfg == nil || cfg.CI == nil || len(cfg.CI.Migrations) == 0 { return nil diff --git a/cmd/wfctl/ci_run_test.go b/cmd/wfctl/ci_run_test.go index 5b74d926..5d67c634 100644 --- a/cmd/wfctl/ci_run_test.go +++ b/cmd/wfctl/ci_run_test.go @@ -168,3 +168,99 @@ func TestRunTestPhase_EmptyTest(t *testing.T) { t.Fatalf("empty test config should not error: %v", err) } } + +func TestRunCIRunTestFallsBackToGoTestWhenNoConfiguredTests(t *testing.T) { + dir := t.TempDir() + writeGoModule(t, dir, `package pkg + +import "testing" + +func TestFallbackRuns(t *testing.T) { + t.Fatal("default go test fallback ran") +} +`) + cfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(cfgPath, []byte(` +version: 1 +`), 0o644); err != nil { + t.Fatal(err) + } + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }() + + err = runCIRun([]string{"--config", cfgPath, "--phase", "test"}) + if err == nil { + t.Fatal("expected fallback go test failure") + } + if !strings.Contains(err.Error(), "test phase failed") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunCIRunLoadsImportedCIConfigForTests(t *testing.T) { + dir := t.TempDir() + writeGoModule(t, dir, `package pkg + +import "testing" + +func TestImportedConfig(t *testing.T) {} +`) + if err := os.WriteFile(filepath.Join(dir, "ci.yaml"), []byte(` +ci: + test: + unit: + command: go test ./pkg +`), 0o644); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(cfgPath, []byte(` +version: 1 +imports: + - ci.yaml +`), 0o644); err != nil { + t.Fatal(err) + } + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }() + + if err := runCIRun([]string{"--config", cfgPath, "--phase", "test"}); err != nil { + t.Fatalf("imported ci.test should run: %v", err) + } +} + +func writeGoModule(t *testing.T, dir, testFile string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.test/wfctl-ci\n\ngo 1.26\n"), 0o644); err != nil { + t.Fatal(err) + } + pkgDir := filepath.Join(dir, "pkg") + if err := os.Mkdir(pkgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pkgDir, "pkg_test.go"), []byte(testFile), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/wfctl/ci_test.go b/cmd/wfctl/ci_test.go index fda13c07..d47662eb 100644 --- a/cmd/wfctl/ci_test.go +++ b/cmd/wfctl/ci_test.go @@ -51,16 +51,10 @@ func TestGenerateGitHubActions(t *testing.T) { if !strings.Contains(buildYML, "GoCodeAlone/setup-wfctl@v1") { t.Error("build.yml missing setup-wfctl action") } - if !strings.Contains(buildYML, "awk '/^[^[:space:]#][^:]*:/") { - t.Error("build.yml missing top-level ci section check") - } - if !strings.Contains(buildYML, "wfctl ci run --config 'infra.yaml' --phase test") { + if !strings.Contains(buildYML, "wfctl ci run --config \"$INFRA_CONFIG\" --phase test") { t.Error("build.yml missing wfctl ci run test phase") } - if !strings.Contains(buildYML, "go test ./...") { - t.Error("build.yml missing fallback go test command") - } - if !strings.Contains(buildYML, "wfctl build --config 'infra.yaml' --no-push --tag ci") { + if !strings.Contains(buildYML, "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci") { t.Error("build.yml missing wfctl build") } if strings.Contains(buildYML, "go build ./...") { @@ -88,10 +82,11 @@ func TestGenerateGitLabCI(t *testing.T) { "rules:", "needs:", "before_script:", + "WFCTL_VERSION: \"latest\"", + "go install \"github.com/GoCodeAlone/workflow/cmd/wfctl@${WFCTL_VERSION}\"", "export PATH=\"$(go env GOPATH)/bin:$PATH\"", "wfctl infra plan", "wfctl ci run --config \"$INFRA_CONFIG\" --phase test", - "go test ./...", "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci", "environment:", } @@ -107,6 +102,53 @@ func TestGenerateGitLabCI(t *testing.T) { } } +func TestCIGeneratePinsCurrentWfctlVersionWhenReleased(t *testing.T) { + origVersion := version + version = "v9.9.9" + defer func() { version = origVersion }() + + ghaFiles, err := generateCIFiles(ciOptions{ + Platform: "github_actions", + InfraConfig: "infra.yaml", + Runner: "ubuntu-latest", + }) + if err != nil { + t.Fatalf("generate GitHub Actions: %v", err) + } + if !strings.Contains(ghaFiles[".github/workflows/build.yml"], "version: 'v9.9.9'") { + t.Fatal("GitHub Actions build workflow should pin the generated wfctl version") + } + + gitlabFiles, err := generateCIFiles(ciOptions{ + Platform: "gitlab_ci", + InfraConfig: "infra.yaml", + }) + if err != nil { + t.Fatalf("generate GitLab CI: %v", err) + } + if !strings.Contains(gitlabFiles[".gitlab-ci.yml"], `WFCTL_VERSION: "v9.9.9"`) { + t.Fatal("GitLab CI workflow should pin the generated wfctl version") + } +} + +func TestCIGenerateUsesLatestForUnreleasedWfctlVersions(t *testing.T) { + origVersion := version + defer func() { version = origVersion }() + + for _, candidate := range []string{ + "", + "dev", + "v0.22.8-0.20260507211020-3f920f7ff2f6", + "v0.22.8-0.20260507211020-3f920f7ff2f6+dirty", + "v9.9.9+dirty", + } { + version = candidate + if got := ciGeneratedWfctlVersion(); got != "latest" { + t.Fatalf("ciGeneratedWfctlVersion(%q) = %q, want latest", candidate, got) + } + } +} + func TestCIGenerateMissingPlatform(t *testing.T) { err := runCIGenerate([]string{}) if err == nil { diff --git a/config/ci_build_security.go b/config/ci_build_security.go index d90500fd..d2cda6e0 100644 --- a/config/ci_build_security.go +++ b/config/ci_build_security.go @@ -1,6 +1,10 @@ package config -import "log" +import ( + "log" + + "gopkg.in/yaml.v3" +) // CIBuildSecurity configures supply-chain hardening for the build phase. type CIBuildSecurity struct { @@ -10,6 +14,7 @@ type CIBuildSecurity struct { Sign bool `json:"sign,omitempty" yaml:"sign,omitempty"` NonRoot bool `json:"non_root" yaml:"non_root"` BaseImagePolicy *CIBaseImagePolicy `json:"base_image_policy,omitempty" yaml:"base_image_policy,omitempty"` + set map[string]bool } // CIBaseImagePolicy restricts which base images may be used in container builds. @@ -18,6 +23,20 @@ type CIBaseImagePolicy struct { DenyPrefixes []string `json:"deny_prefixes,omitempty" yaml:"deny_prefixes,omitempty"` } +func (s *CIBuildSecurity) UnmarshalYAML(value *yaml.Node) error { + type plain CIBuildSecurity + var decoded plain + if err := value.Decode(&decoded); err != nil { + return err + } + *s = CIBuildSecurity(decoded) + s.set = make(map[string]bool) + for i := 0; i+1 < len(value.Content); i += 2 { + s.set[value.Content[i].Value] = true + } + return nil +} + // ApplyDefaults returns a CIBuildSecurity with opinionated secure defaults applied. // If the receiver is nil, a fully-hardened default struct is returned. // If the receiver is non-nil, only the Provenance field is defaulted when empty; diff --git a/config/config.go b/config/config.go index f2b92d88..d2f4b51b 100644 --- a/config/config.go +++ b/config/config.go @@ -331,6 +331,8 @@ func (cfg *WorkflowConfig) processImports(seen map[string]bool) error { } } + mergeImportedCI(cfg, impCfg.CI) + // Merge external plugin declarations — deduplicate by name (first definition wins) if impCfg.Plugins != nil && len(impCfg.Plugins.External) > 0 { if cfg.Plugins == nil { @@ -458,6 +460,220 @@ func (cfg *WorkflowConfig) processImports(seen map[string]bool) error { return nil } +func mergeImportedCI(cfg *WorkflowConfig, imported *CIConfig) { + if imported == nil { + return + } + if cfg.CI == nil { + cfg.CI = imported + return + } + mergeImportedCIBuild(cfg.CI.Build, imported.Build, func(build *CIBuildConfig) { + cfg.CI.Build = build + }) + mergeImportedCITest(cfg.CI.Test, imported.Test, func(test *CITestConfig) { + cfg.CI.Test = test + }) + if cfg.CI.Deploy == nil { + cfg.CI.Deploy = imported.Deploy + } else if imported.Deploy != nil { + if cfg.CI.Deploy.Environments == nil { + cfg.CI.Deploy.Environments = make(map[string]*CIDeployEnvironment, len(imported.Deploy.Environments)) + } + for k, v := range imported.Deploy.Environments { + if _, exists := cfg.CI.Deploy.Environments[k]; !exists { + cfg.CI.Deploy.Environments[k] = v + } + } + } + if cfg.CI.Infra == nil { + cfg.CI.Infra = imported.Infra + } + if len(imported.Registries) > 0 { + existing := make(map[string]struct{}, len(cfg.CI.Registries)) + for _, registry := range cfg.CI.Registries { + existing[registry.Name] = struct{}{} + } + for _, registry := range imported.Registries { + if _, exists := existing[registry.Name]; exists { + continue + } + cfg.CI.Registries = append(cfg.CI.Registries, registry) + existing[registry.Name] = struct{}{} + } + } + if len(imported.Migrations) > 0 { + existing := make(map[string]struct{}, len(cfg.CI.Migrations)) + for _, migration := range cfg.CI.Migrations { + existing[migration.Name] = struct{}{} + } + for _, migration := range imported.Migrations { + if _, exists := existing[migration.Name]; exists { + continue + } + cfg.CI.Migrations = append(cfg.CI.Migrations, migration) + existing[migration.Name] = struct{}{} + } + } +} + +func mergeImportedCIBuild(current, imported *CIBuildConfig, set func(*CIBuildConfig)) { + if imported == nil { + return + } + if current == nil { + set(imported) + return + } + current.Targets = appendMissingCITargets(current.Targets, imported.Targets) + current.Containers = appendMissingCIContainers(current.Containers, imported.Containers) + current.Assets = appendMissingCIAssets(current.Assets, imported.Assets) + mergeImportedCIBuildSecurity(current.Security, imported.Security, func(security *CIBuildSecurity) { + current.Security = security + }) +} + +func mergeImportedCITest(current, imported *CITestConfig, set func(*CITestConfig)) { + if imported == nil { + return + } + if current == nil { + set(imported) + return + } + if current.Unit == nil { + current.Unit = imported.Unit + } + if current.Integration == nil { + current.Integration = imported.Integration + } + if current.E2E == nil { + current.E2E = imported.E2E + } +} + +func mergeImportedCIBuildSecurity(current, imported *CIBuildSecurity, set func(*CIBuildSecurity)) { + if imported == nil { + return + } + if current == nil { + set(imported) + return + } + fillBoolSecurityField(current, imported, "hardened", ¤t.Hardened, imported.Hardened) + fillBoolSecurityField(current, imported, "sbom", ¤t.SBOM, imported.SBOM) + fillStringSecurityField(current, imported, "provenance", ¤t.Provenance, imported.Provenance) + fillBoolSecurityField(current, imported, "sign", ¤t.Sign, imported.Sign) + fillBoolSecurityField(current, imported, "non_root", ¤t.NonRoot, imported.NonRoot) + if !securityFieldSet(current, "base_image_policy") && securityFieldAvailable(imported, "base_image_policy") { + current.BaseImagePolicy = imported.BaseImagePolicy + markSecurityFieldSet(current, "base_image_policy") + } +} + +func fillBoolSecurityField(current, imported *CIBuildSecurity, field string, target *bool, importedValue bool) { + if securityFieldSet(current, field) || !securityFieldAvailable(imported, field) { + return + } + *target = importedValue + markSecurityFieldSet(current, field) +} + +func fillStringSecurityField(current, imported *CIBuildSecurity, field string, target *string, importedValue string) { + if securityFieldSet(current, field) || !securityFieldAvailable(imported, field) { + return + } + *target = importedValue + markSecurityFieldSet(current, field) +} + +func securityFieldSet(security *CIBuildSecurity, field string) bool { + if security == nil { + return false + } + if len(security.set) > 0 { + return security.set[field] + } + switch field { + case "hardened": + return security.Hardened + case "sbom": + return security.SBOM + case "provenance": + return security.Provenance != "" + case "sign": + return security.Sign + case "non_root": + return security.NonRoot + case "base_image_policy": + return security.BaseImagePolicy != nil + default: + return false + } +} + +func securityFieldAvailable(security *CIBuildSecurity, field string) bool { + if security == nil { + return false + } + if len(security.set) > 0 { + return security.set[field] + } + return securityFieldSet(security, field) +} + +func markSecurityFieldSet(security *CIBuildSecurity, field string) { + if security.set == nil { + security.set = make(map[string]bool) + } + security.set[field] = true +} + +func appendMissingCITargets(current, imported []CITarget) []CITarget { + existing := make(map[string]struct{}, len(current)) + for _, target := range current { + existing[target.Name] = struct{}{} + } + for _, target := range imported { + if _, exists := existing[target.Name]; exists { + continue + } + current = append(current, target) + existing[target.Name] = struct{}{} + } + return current +} + +func appendMissingCIContainers(current, imported []CIContainerTarget) []CIContainerTarget { + existing := make(map[string]struct{}, len(current)) + for _, target := range current { + existing[target.Name] = struct{}{} + } + for _, target := range imported { + if _, exists := existing[target.Name]; exists { + continue + } + current = append(current, target) + existing[target.Name] = struct{}{} + } + return current +} + +func appendMissingCIAssets(current, imported []CIAssetTarget) []CIAssetTarget { + existing := make(map[string]struct{}, len(current)) + for _, target := range current { + existing[target.Name] = struct{}{} + } + for _, target := range imported { + if _, exists := existing[target.Name]; exists { + continue + } + current = append(current, target) + existing[target.Name] = struct{}{} + } + return current +} + // LoadFromBytes loads a workflow configuration from a YAML byte slice. // This is useful for loading embedded configs (e.g. via //go:embed). // Note: imports are NOT processed because there is no file path context diff --git a/config/config_import_test.go b/config/config_import_test.go index e83d59b6..f710648e 100644 --- a/config/config_import_test.go +++ b/config/config_import_test.go @@ -745,6 +745,188 @@ secrets: } } +func TestLoadFromFile_ImportCIMerge(t *testing.T) { + dir := t.TempDir() + + importedYAML := ` +ci: + build: + targets: + - name: imported-bin + type: go + path: ./cmd/imported + - name: shared-bin + type: go + path: ./cmd/imported-shared + containers: + - name: imported-image + dockerfile: Dockerfile.imported + assets: + - name: imported-ui + build: npm run build + path: dist + security: + hardened: true + sbom: true + provenance: slsa-3 + non_root: true + base_image_policy: + deny_prefixes: + - docker.io/library + test: + unit: + command: go test ./... + deploy: + environments: + staging: + provider: digitalocean + registries: + - name: shared + type: digitalocean + path: registry.digitalocean.com/shared/app + migrations: + - name: app + source_dir: migrations + database: + env: DATABASE_URL +` + if err := os.WriteFile(filepath.Join(dir, "ci.yaml"), []byte(importedYAML), 0644); err != nil { + t.Fatal(err) + } + + mainYAML := ` +imports: + - ci.yaml +ci: + build: + targets: + - name: shared-bin + type: go + path: ./cmd/local-shared + assets: + - name: local-ui + build: npm run local + path: local-dist + security: + sign: true + test: + integration: + command: go test ./integration + deploy: + environments: + production: + provider: digitalocean +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.CI == nil { + t.Fatal("expected merged CI config") + } + if cfg.CI.Build == nil { + t.Fatal("expected main ci.build to survive merge") + } + if len(cfg.CI.Build.Targets) != 2 { + t.Fatalf("expected imported and main build targets, got %#v", cfg.CI.Build.Targets) + } + targetsByName := map[string]CITarget{} + for _, target := range cfg.CI.Build.Targets { + targetsByName[target.Name] = target + } + if targetsByName["shared-bin"].Path != "./cmd/local-shared" { + t.Fatalf("expected main target to win shared-bin conflict, got %#v", targetsByName["shared-bin"]) + } + if targetsByName["imported-bin"].Path != "./cmd/imported" { + t.Fatalf("expected imported target to be appended, got %#v", targetsByName["imported-bin"]) + } + if len(cfg.CI.Build.Containers) != 1 || cfg.CI.Build.Containers[0].Name != "imported-image" { + t.Fatalf("expected imported container, got %#v", cfg.CI.Build.Containers) + } + if len(cfg.CI.Build.Assets) != 2 { + t.Fatalf("expected imported and main assets, got %#v", cfg.CI.Build.Assets) + } + if cfg.CI.Build.Security == nil { + t.Fatal("expected merged build security") + } + if !cfg.CI.Build.Security.Sign { + t.Fatal("expected main build security sign=true to survive merge") + } + if !cfg.CI.Build.Security.Hardened || !cfg.CI.Build.Security.SBOM || !cfg.CI.Build.Security.NonRoot { + t.Fatalf("expected imported bool security fields to fill missing main fields, got %#v", cfg.CI.Build.Security) + } + if cfg.CI.Build.Security.BaseImagePolicy == nil || + len(cfg.CI.Build.Security.BaseImagePolicy.DenyPrefixes) != 1 || + cfg.CI.Build.Security.BaseImagePolicy.DenyPrefixes[0] != "docker.io/library" { + t.Fatalf("expected imported base image policy, got %#v", cfg.CI.Build.Security.BaseImagePolicy) + } + if cfg.CI.Test == nil || cfg.CI.Test.Unit == nil || cfg.CI.Test.Integration == nil { + t.Fatalf("expected imported unit and main integration test phases, got %#v", cfg.CI.Test) + } + if cfg.CI.Test.Unit.Command != "go test ./..." { + t.Errorf("unit command: got %q", cfg.CI.Test.Unit.Command) + } + if cfg.CI.Test.Integration.Command != "go test ./integration" { + t.Errorf("integration command: got %q", cfg.CI.Test.Integration.Command) + } + if cfg.CI.Deploy == nil || len(cfg.CI.Deploy.Environments) != 2 { + t.Fatalf("expected imported and main deploy envs, got %#v", cfg.CI.Deploy) + } + if len(cfg.CI.Registries) != 1 || cfg.CI.Registries[0].Name != "shared" { + t.Fatalf("expected imported registry, got %#v", cfg.CI.Registries) + } + if len(cfg.CI.Migrations) != 1 || cfg.CI.Migrations[0].Name != "app" { + t.Fatalf("expected imported migration, got %#v", cfg.CI.Migrations) + } +} + +func TestLoadFromFile_ImportCIMergeParentBaseImagePolicyWins(t *testing.T) { + dir := t.TempDir() + + importedYAML := ` +ci: + build: + security: + base_image_policy: + deny_prefixes: + - docker.io/library +` + if err := os.WriteFile(filepath.Join(dir, "ci.yaml"), []byte(importedYAML), 0644); err != nil { + t.Fatal(err) + } + + mainYAML := ` +imports: + - ci.yaml +ci: + build: + security: + base_image_policy: + allow_prefixes: [] + deny_prefixes: [] +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.CI == nil || cfg.CI.Build == nil || cfg.CI.Build.Security == nil || cfg.CI.Build.Security.BaseImagePolicy == nil { + t.Fatalf("expected main base image policy to survive, got %#v", cfg.CI) + } + if len(cfg.CI.Build.Security.BaseImagePolicy.DenyPrefixes) != 0 { + t.Fatalf("expected explicit empty parent deny_prefixes to win, got %#v", cfg.CI.Build.Security.BaseImagePolicy.DenyPrefixes) + } +} + // TestLoadFromFile_ImportSecretsOnlyInImport covers the case the original // PR-1 fix missed: top-level `secrets:` appears only in an imported file, // not in main. Without the merge, cfg.Secrets is nil after LoadFromFile. From 3df134b879f82cf686f48ab58f3c9ecb17e0c136 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 18:54:44 -0400 Subject: [PATCH 5/6] fix(wfctl): satisfy CI lint --- config/config.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/config/config.go b/config/config.go index d2f4b51b..5d749f89 100644 --- a/config/config.go +++ b/config/config.go @@ -504,14 +504,16 @@ func mergeImportedCI(cfg *WorkflowConfig, imported *CIConfig) { } if len(imported.Migrations) > 0 { existing := make(map[string]struct{}, len(cfg.CI.Migrations)) - for _, migration := range cfg.CI.Migrations { + for i := range cfg.CI.Migrations { + migration := &cfg.CI.Migrations[i] existing[migration.Name] = struct{}{} } - for _, migration := range imported.Migrations { + for i := range imported.Migrations { + migration := &imported.Migrations[i] if _, exists := existing[migration.Name]; exists { continue } - cfg.CI.Migrations = append(cfg.CI.Migrations, migration) + cfg.CI.Migrations = append(cfg.CI.Migrations, *migration) existing[migration.Name] = struct{}{} } } @@ -631,14 +633,16 @@ func markSecurityFieldSet(security *CIBuildSecurity, field string) { func appendMissingCITargets(current, imported []CITarget) []CITarget { existing := make(map[string]struct{}, len(current)) - for _, target := range current { + for i := range current { + target := ¤t[i] existing[target.Name] = struct{}{} } - for _, target := range imported { + for i := range imported { + target := &imported[i] if _, exists := existing[target.Name]; exists { continue } - current = append(current, target) + current = append(current, *target) existing[target.Name] = struct{}{} } return current @@ -646,14 +650,16 @@ func appendMissingCITargets(current, imported []CITarget) []CITarget { func appendMissingCIContainers(current, imported []CIContainerTarget) []CIContainerTarget { existing := make(map[string]struct{}, len(current)) - for _, target := range current { + for i := range current { + target := ¤t[i] existing[target.Name] = struct{}{} } - for _, target := range imported { + for i := range imported { + target := &imported[i] if _, exists := existing[target.Name]; exists { continue } - current = append(current, target) + current = append(current, *target) existing[target.Name] = struct{}{} } return current From e96d2d3ca415760c1e4f6cd247a3afc66280c680 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 9 May 2026 19:15:31 -0400 Subject: [PATCH 6/6] fix(wfctl): compile fallback build jobs --- cmd/wfctl/build.go | 43 ++++++++++++---- cmd/wfctl/build_orchestrate_test.go | 23 +++++---- cmd/wfctl/build_test.go | 79 +++++++++++++++++++++++++++++ cmd/wfctl/ci.go | 4 +- cmd/wfctl/ci_test.go | 4 +- cmd/wfctl/secrets_detect.go | 15 +++++- 6 files changed, 143 insertions(+), 25 deletions(-) diff --git a/cmd/wfctl/build.go b/cmd/wfctl/build.go index 083444dc..27d82c97 100644 --- a/cmd/wfctl/build.go +++ b/cmd/wfctl/build.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "os/exec" "strings" "github.com/GoCodeAlone/workflow/config" @@ -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)") @@ -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") @@ -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 } @@ -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 diff --git a/cmd/wfctl/build_orchestrate_test.go b/cmd/wfctl/build_orchestrate_test.go index 2fe03eae..5b04bf1f 100644 --- a/cmd/wfctl/build_orchestrate_test.go +++ b/cmd/wfctl/build_orchestrate_test.go @@ -241,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] { @@ -252,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, } } diff --git a/cmd/wfctl/build_test.go b/cmd/wfctl/build_test.go index 03e57fb3..fa43139b 100644 --- a/cmd/wfctl/build_test.go +++ b/cmd/wfctl/build_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/GoCodeAlone/workflow/config" ) func TestRunBuild_DryRunPrintsPlannedActions(t *testing.T) { @@ -42,3 +44,80 @@ func TestRunBuild_UnknownSubcommandError(t *testing.T) { t.Fatalf("error should mention unknown subcommand, got: %v", err) } } + +func TestRunBuild_NoBuildConfigSkipsByDefault(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(cfgPath, []byte("name: fallback-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + out, err := captureStdout(t, func() error { + return runBuild([]string{"--config", cfgPath}) + }) + if err != nil { + t.Fatalf("runBuild without fallback: %v", err) + } + if !strings.Contains(out, "No build configuration, skipping build phase") { + t.Fatalf("expected default skip message, got:\n%s", out) + } +} + +func TestRunBuild_FallbackGoBuildDryRun(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(cfgPath, []byte("name: fallback-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + out, err := captureStdout(t, func() error { + return runBuild([]string{"--config", cfgPath, "--fallback-go-build", "--dry-run"}) + }) + if err != nil { + t.Fatalf("runBuild fallback dry-run: %v", err) + } + if !strings.Contains(out, "[dry-run] go build ./...") { + t.Fatalf("expected go build dry-run fallback, got:\n%s", out) + } +} + +func TestRunBuild_FallbackGoBuildRunsWhenNoTargets(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(cfgPath, []byte("name: fallback-test\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fallback.test\n\ngo 1.26\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "broken.go"), []byte("package main\n\nfunc main() { missing }\n"), 0o600); err != nil { + t.Fatal(err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }() + + err = runBuild([]string{"--config", cfgPath, "--fallback-go-build"}) + if err == nil { + t.Fatal("expected fallback go build to run and fail on uncompilable source") + } +} + +func TestHasConfiguredBuildTargetsIgnoresSecurityOnlyConfig(t *testing.T) { + build := &config.CIBuildConfig{ + Security: &config.CIBuildSecurity{Hardened: true}, + } + if hasConfiguredBuildTargets(build) { + t.Fatal("security-only build config should not count as configured build targets") + } +} diff --git a/cmd/wfctl/ci.go b/cmd/wfctl/ci.go index 35560060..caea9e64 100644 --- a/cmd/wfctl/ci.go +++ b/cmd/wfctl/ci.go @@ -298,7 +298,7 @@ jobs: - name: Build without push env: INFRA_CONFIG: '{{.InfraConfig}}' - run: wfctl build --config "$INFRA_CONFIG" --no-push --tag ci + run: wfctl build --config "$INFRA_CONFIG" --no-push --tag ci --fallback-go-build ` // ── GitLab CI ───────────────────────────────────────────────────────────────── @@ -373,7 +373,7 @@ build: needs: [] script: - wfctl ci run --config "$INFRA_CONFIG" --phase test - - wfctl build --config "$INFRA_CONFIG" --no-push --tag ci + - wfctl build --config "$INFRA_CONFIG" --no-push --tag ci --fallback-go-build rules: - if: $CI_COMMIT_BRANCH == "{{.Branch}}" - if: $CI_PIPELINE_SOURCE == "merge_request_event" diff --git a/cmd/wfctl/ci_test.go b/cmd/wfctl/ci_test.go index d47662eb..eda6a195 100644 --- a/cmd/wfctl/ci_test.go +++ b/cmd/wfctl/ci_test.go @@ -54,7 +54,7 @@ func TestGenerateGitHubActions(t *testing.T) { if !strings.Contains(buildYML, "wfctl ci run --config \"$INFRA_CONFIG\" --phase test") { t.Error("build.yml missing wfctl ci run test phase") } - if !strings.Contains(buildYML, "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci") { + if !strings.Contains(buildYML, "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci --fallback-go-build") { t.Error("build.yml missing wfctl build") } if strings.Contains(buildYML, "go build ./...") { @@ -87,7 +87,7 @@ func TestGenerateGitLabCI(t *testing.T) { "export PATH=\"$(go env GOPATH)/bin:$PATH\"", "wfctl infra plan", "wfctl ci run --config \"$INFRA_CONFIG\" --phase test", - "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci", + "wfctl build --config \"$INFRA_CONFIG\" --no-push --tag ci --fallback-go-build", "environment:", } for _, m := range markers { diff --git a/cmd/wfctl/secrets_detect.go b/cmd/wfctl/secrets_detect.go index 5956917c..e8c68492 100644 --- a/cmd/wfctl/secrets_detect.go +++ b/cmd/wfctl/secrets_detect.go @@ -183,7 +183,11 @@ func runSecretsSetWithReader(args []string, r io.Reader) error { secretValue = strings.TrimRight(string(b), "\n") case isatty.IsTerminal(os.Stdin.Fd()): // interactive: masked prompt fmt.Fprintf(os.Stderr, "Value for %s: ", name) - b, err := term.ReadPassword(int(os.Stdin.Fd())) + fd, err := stdinFileDescriptor() + if err != nil { + return err + } + b, err := term.ReadPassword(fd) if err != nil { return fmt.Errorf("read password: %w", err) } @@ -222,6 +226,15 @@ func runSecretsSetWithReader(args []string, r io.Reader) error { return nil } +func stdinFileDescriptor() (int, error) { + fd := os.Stdin.Fd() + maxInt := int(^uint(0) >> 1) + if fd > uintptr(maxInt) { + return 0, fmt.Errorf("stdin file descriptor %d exceeds supported int range", fd) + } + return int(fd), nil //nolint:gosec // fd is range-checked before conversion. +} + func runSecretsList(args []string) error { fs := flag.NewFlagSet("secrets list", flag.ContinueOnError) configFile := fs.String("config", "app.yaml", "Workflow config file")