diff --git a/cigen/render_circleci.go b/cigen/render_circleci.go new file mode 100644 index 00000000..5891ea48 --- /dev/null +++ b/cigen/render_circleci.go @@ -0,0 +1,149 @@ +package cigen + +import ( + "fmt" + "strings" +) + +// RenderCircleCI generates a CircleCI 2.1 configuration from a CIPlan. +// It returns a map with a single key ".circleci/config.yml". +// +// The output mirrors the GitHub Actions renderer's job set — plan / per-phase +// apply (plan-guard + last-phase migrations) / smoke — as CircleCI jobs wired by +// a `workflows:` graph (plan jobs on PR branches, apply jobs on the default +// branch chained via `requires:`). CircleCI auto-injects project-level env vars +// into every job, so secrets are referenced, not re-declared. It deliberately +// emits no docker-build/deploy stage (ADR 0044). +func RenderCircleCI(p *CIPlan) (map[string]string, error) { + if p == nil { + return nil, fmt.Errorf("RenderCircleCI: plan is nil") + } + content, err := renderCircleCIConfig(p) + if err != nil { + return nil, err + } + return map[string]string{".circleci/config.yml": content}, nil +} + +func renderCircleCIConfig(p *CIPlan) (string, error) { + branch := p.DefaultBranch + if branch == "" { + branch = "main" + } + version := p.WfctlVersion + if version == "" { + version = "latest" + } + + var b strings.Builder + b.WriteString("version: 2.1\n") + // Project-level env vars (secrets) are auto-injected into every job by + // CircleCI; set these in the project settings. They are referenced, never + // re-declared as NAME: $NAME no-ops. + if creds := secretUnion(p); len(creds) > 0 { + fmt.Fprintf(&b, "# Required project environment variables: %s\n", strings.Join(creds, ", ")) + } + b.WriteString("\n") + + // Jobs + b.WriteString("jobs:\n") + for _, phase := range p.Phases { + writeCirclePlanJob(&b, circleJobName("plan", phase, p), phase, p, version) + } + for i, phase := range p.Phases { + writeCircleApplyJob(&b, circleJobName("apply", phase, p), phase, p, version, i == len(p.Phases)-1) + } + if p.Smoke != nil { + b.WriteString(" smoke:\n") + b.WriteString(" docker:\n - image: cimg/base:current\n") + b.WriteString(" steps:\n") + fmt.Fprintf(&b, " - run: curl --fail --max-time 30 '%s'\n", p.Smoke.URL) + } + + // Workflow graph + b.WriteString("\nworkflows:\n") + b.WriteString(" infra:\n") + b.WriteString(" jobs:\n") + prevApply := "" + for _, phase := range p.Phases { + planJob := circleJobName("plan", phase, p) + applyJob := circleJobName("apply", phase, p) + // Plan jobs run on non-default branches (i.e. PRs). + fmt.Fprintf(&b, " - %s:\n", planJob) + b.WriteString(" filters:\n branches:\n") + fmt.Fprintf(&b, " ignore:\n - %s\n", branch) + // Apply jobs run on the default branch, chained via requires:. + fmt.Fprintf(&b, " - %s:\n", applyJob) + if prevApply != "" { + fmt.Fprintf(&b, " requires:\n - %s\n", prevApply) + } + b.WriteString(" filters:\n branches:\n") + fmt.Fprintf(&b, " only:\n - %s\n", branch) + prevApply = applyJob + } + if p.Smoke != nil { + b.WriteString(" - smoke:\n") + fmt.Fprintf(&b, " requires:\n - %s\n", prevApply) + b.WriteString(" filters:\n branches:\n") + fmt.Fprintf(&b, " only:\n - %s\n", branch) + } + + return b.String(), nil +} + +// circleJobName returns the phase-suffixed job name for multi-phase plans, or the +// bare prefix for single-phase plans (mirrors the GitLab renderer's naming). +func circleJobName(prefix string, phase DeployPhase, p *CIPlan) string { + if len(p.Phases) > 1 { + return fmt.Sprintf("%s-%s", prefix, phase.Name) + } + return prefix +} + +func writeCirclePlanJob(b *strings.Builder, jobName string, phase DeployPhase, p *CIPlan, version string) { + fmt.Fprintf(b, " %s:\n", jobName) + b.WriteString(" docker:\n - image: cimg/go:1.26\n") + b.WriteString(" steps:\n") + b.WriteString(" - checkout\n") + writeCircleSetup(b, p, phase, version) + fmt.Fprintf(b, " - run: wfctl infra plan --config '%s' --format markdown\n", phase.ConfigPath) +} + +func writeCircleApplyJob(b *strings.Builder, jobName string, phase DeployPhase, p *CIPlan, version string, isLast bool) { + fmt.Fprintf(b, " %s:\n", jobName) + b.WriteString(" docker:\n - image: cimg/go:1.26\n") + b.WriteString(" steps:\n") + b.WriteString(" - checkout\n") + writeCircleSetup(b, p, phase, version) + if p.PlanGuard { + writeCirclePlanGuard(b, phase.ConfigPath) + } + // Migrations run only in the last phase, via the shared `wfctl migrations up` + // runner (never `wfctl ci run --phase migrate`). + if isLast && p.Migrations != nil { + fmt.Fprintf(b, " - run: %s\n", migrationsUpCommand(phase.ConfigPath, p.Migrations.Env)) + } + fmt.Fprintf(b, " - run: wfctl infra apply --config '%s' --auto-approve\n", phase.ConfigPath) +} + +// writeCircleSetup installs wfctl (and plugins when needed) for the job. +func writeCircleSetup(b *strings.Builder, p *CIPlan, phase DeployPhase, version string) { + fmt.Fprintf(b, " - run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@%s\n", version) + if p.PluginInstall { + fmt.Fprintf(b, " - run: wfctl plugin install --config '%s'\n", phase.ConfigPath) + } +} + +// writeCirclePlanGuard refuses to apply when the plan includes a replace or +// destroy of a protected resource (exit 1, no `|| true`). +func writeCirclePlanGuard(b *strings.Builder, configPath string) { + b.WriteString(" - run:\n") + b.WriteString(" name: Plan guard\n") + b.WriteString(" command: |\n") + fmt.Fprintf(b, " wfctl infra plan --config '%s' | tee plan-guard.txt\n", configPath) + b.WriteString(" if grep -Eq -- '^[[:space:]]*(- delete|-/\\+ replace)[[:space:]]' plan-guard.txt || \\\n") + b.WriteString(" grep -Eq -- 'Plan: .*([1-9][0-9]* to replace|[1-9][0-9]* to destroy)' plan-guard.txt; then\n") + b.WriteString(" echo 'Refusing apply: plan includes replace or destroy of a protected resource.' >&2\n") + b.WriteString(" exit 1\n") + b.WriteString(" fi\n") +} diff --git a/cigen/render_circleci_test.go b/cigen/render_circleci_test.go new file mode 100644 index 00000000..1ea14108 --- /dev/null +++ b/cigen/render_circleci_test.go @@ -0,0 +1,80 @@ +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" + "gopkg.in/yaml.v3" +) + +func TestRenderCircleCI_ValidYAMLAndStructure(t *testing.T) { + files, err := cigen.RenderCircleCI(richCIPlan()) + if err != nil { + t.Fatalf("RenderCircleCI: %v", err) + } + content, ok := files[".circleci/config.yml"] + if !ok { + t.Fatal("expected .circleci/config.yml in output") + } + var parsed any + if err := yaml.Unmarshal([]byte(content), &parsed); err != nil { + t.Fatalf("not valid YAML: %v\n%s", err, content) + } + must := []string{ + "version: 2.1", + "workflows:", + "plan-prereq", "plan-deploy", + "apply-prereq", "apply-deploy", + "requires:", // CircleCI graph keyword (NOT GHA needs:) + "wfctl migrations up", "--format json", + "wfctl infra apply --config 'deploy.yaml' --auto-approve", + "curl --fail --max-time 30 'https://myapp.example.com/healthz'", + } + for _, m := range must { + if !strings.Contains(content, m) { + t.Errorf(".circleci/config.yml missing %q\n---\n%s", m, content) + } + } + if strings.Contains(content, "needs:") { + t.Error("CircleCI uses requires:, not GHA needs:") + } + // Positive secret-wiring: each secret name must appear (referenced by an apply + // job), so a renderer that emits NO secret wiring fails this. + for _, s := range richCIPlan().Secrets { + if !strings.Contains(content, s.Name) { + t.Errorf("expected secret %s referenced in output", s.Name) + } + // CircleCI auto-injects project env vars; no redundant NAME: $NAME re-declare. + if strings.Contains(content, " "+s.Name+": $"+s.Name) { + t.Errorf("redundant secret re-declare for %s", s.Name) + } + } + if !strings.Contains(content, "exit 1") { + t.Error("expected plan-guard exit 1") + } + for _, banned := range []string{"go test ./...", "wfctl deploy --image", "docker build", "wfctl ci run --phase migrate"} { + if strings.Contains(content, banned) { + t.Errorf("must NOT contain legacy %q", banned) + } + } +} + +func TestRenderCircleCI_NilPlan(t *testing.T) { + if _, err := cigen.RenderCircleCI(nil); err == nil { + t.Error("expected error for nil plan") + } +} + +func TestRenderCircleCI_SinglePhase(t *testing.T) { + p := richCIPlan() + p.Phases = []cigen.DeployPhase{{Name: "deploy", ConfigPath: "deploy.yaml"}} + files, err := cigen.RenderCircleCI(p) + if err != nil { + t.Fatalf("single-phase: %v", err) + } + content := files[".circleci/config.yml"] + if !strings.Contains(content, "apply") { + t.Error("expected an apply job for single phase") + } +} diff --git a/cigen/render_jenkins.go b/cigen/render_jenkins.go new file mode 100644 index 00000000..98d88e19 --- /dev/null +++ b/cigen/render_jenkins.go @@ -0,0 +1,179 @@ +package cigen + +import ( + "fmt" + "sort" + "strings" +) + +// RenderJenkins generates a declarative Jenkinsfile from a CIPlan. +// It returns a map with a single key "Jenkinsfile". +// +// The output mirrors the GitHub Actions renderer's job set — plan / per-phase +// apply (scoped secrets + plan-guard + last-phase migrations) / smoke — mapped +// onto a single linear declarative `stages {}` block. Plan stages gate on +// changeRequest() (PR branches); apply/smoke stages gate on the default branch, +// which is correct only in a Jenkins Multibranch Pipeline (surfaced via a header +// comment). It deliberately emits no docker-build/deploy stage (ADR 0044). +func RenderJenkins(p *CIPlan) (map[string]string, error) { + if p == nil { + return nil, fmt.Errorf("RenderJenkins: plan is nil") + } + content, err := renderJenkinsfile(p) + if err != nil { + return nil, err + } + return map[string]string{"Jenkinsfile": content}, nil +} + +func renderJenkinsfile(p *CIPlan) (string, error) { + branch := p.DefaultBranch + if branch == "" { + branch = "main" + } + version := p.WfctlVersion + if version == "" { + version = "latest" + } + + var b strings.Builder + + // Header: Multibranch precondition + sorted required-credentials union so the + // operator knows which Jenkins credentials to pre-create. + b.WriteString("// Requires a Jenkins Multibranch Pipeline job (generated by wfctl ci generate).\n") + b.WriteString("// Plan stages gate on changeRequest() (PR branches); apply/smoke stages gate\n") + b.WriteString("// on the default branch. In a standard single-branch job, changeRequest() is\n") + b.WriteString("// always false and plan stages will not run.\n") + if creds := secretUnion(p); len(creds) > 0 { + fmt.Fprintf(&b, "// Required Jenkins credentials: %s\n", strings.Join(creds, ", ")) + } + + b.WriteString("pipeline {\n") + b.WriteString(" agent { label 'linux' }\n") + b.WriteString(" environment {\n") + b.WriteString(" GOBIN = \"${WORKSPACE}/bin\"\n") + b.WriteString(" PATH = \"${WORKSPACE}/bin:${PATH}\"\n") + fmt.Fprintf(&b, " WFCTL_VERSION = %q\n", version) + b.WriteString(" }\n") + b.WriteString(" stages {\n") + + // Plan stages (one per phase), gated on changeRequest(). + for _, phase := range p.Phases { + fmt.Fprintf(&b, " stage('Plan %s') {\n", phase.Name) + b.WriteString(" when { changeRequest() }\n") + b.WriteString(" steps {\n") + writeJenkinsSetup(&b, p, phase) + fmt.Fprintf(&b, " sh \"wfctl infra plan --config '%s' --format markdown\"\n", phase.ConfigPath) + b.WriteString(" }\n") + b.WriteString(" }\n") + } + + // Apply stages (one per phase, in declarative order), gated on the default + // branch. Per-stage environment {} gives the per-phase secret scope the GHA + // renderer gets via per-job env:. + for i, phase := range p.Phases { + fmt.Fprintf(&b, " stage('Apply %s') {\n", phase.Name) + fmt.Fprintf(&b, " when { branch '%s' }\n", branch) + + // Source the secret set by branching on phase.Scoped (NOT len): a scoped + // phase uses its own subset; an unscoped phase falls back to the union. + secrets := p.Secrets + if phase.Scoped { + secrets = phase.Secrets + } + if len(secrets) > 0 { + b.WriteString(" environment {\n") + for _, name := range sortedSecretNames(secrets) { + fmt.Fprintf(&b, " %s = credentials('%s')\n", name, name) + } + b.WriteString(" }\n") + } + + b.WriteString(" steps {\n") + writeJenkinsSetup(&b, p, phase) + if p.PlanGuard { + writeJenkinsPlanGuard(&b, phase.ConfigPath) + } + // Migrations run only in the last phase, via the shared `wfctl migrations + // up` runner (never `wfctl ci run --phase migrate`). + if i == len(p.Phases)-1 && p.Migrations != nil { + fmt.Fprintf(&b, " sh \"%s\"\n", migrationsUpCommand(phase.ConfigPath, p.Migrations.Env)) + } + fmt.Fprintf(&b, " sh \"wfctl infra apply --config '%s' --auto-approve\"\n", phase.ConfigPath) + b.WriteString(" }\n") + b.WriteString(" }\n") + } + + // Smoke stage. + if p.Smoke != nil { + b.WriteString(" stage('Smoke') {\n") + fmt.Fprintf(&b, " when { branch '%s' }\n", branch) + b.WriteString(" steps {\n") + fmt.Fprintf(&b, " sh \"curl --fail --max-time 30 '%s'\"\n", p.Smoke.URL) + b.WriteString(" }\n") + b.WriteString(" }\n") + } + + b.WriteString(" }\n") + b.WriteString("}\n") + return b.String(), nil +} + +// writeJenkinsSetup installs wfctl into the workspace bin (on PATH via the +// pipeline environment) and, when needed, installs plugins for the phase config. +func writeJenkinsSetup(b *strings.Builder, p *CIPlan, phase DeployPhase) { + // Single-quoted so the shell (not Groovy) expands the exported WFCTL_VERSION. + b.WriteString(" sh 'go install github.com/GoCodeAlone/workflow/cmd/wfctl@${WFCTL_VERSION}'\n") + if p.PluginInstall { + fmt.Fprintf(b, " sh \"wfctl plugin install --config '%s'\"\n", phase.ConfigPath) + } +} + +// writeJenkinsPlanGuard refuses to apply when the plan includes a replace or +// destroy of a protected resource. The plan output stays in the build log (tee) +// and a destructive plan fails the stage (exit 1) — no `|| true`. +func writeJenkinsPlanGuard(b *strings.Builder, configPath string) { + b.WriteString(" sh '''\n") + fmt.Fprintf(b, " wfctl infra plan --config '%s' | tee plan-guard.txt\n", configPath) + b.WriteString(" if grep -Eq -- '^[[:space:]]*(- delete|-/\\+ replace)[[:space:]]' plan-guard.txt || \\\n") + b.WriteString(" grep -Eq -- 'Plan: .*([1-9][0-9]* to replace|[1-9][0-9]* to destroy)' plan-guard.txt; then\n") + b.WriteString(" echo \"Refusing apply: plan includes replace or destroy of a protected resource.\" >&2\n") + b.WriteString(" exit 1\n") + b.WriteString(" fi\n") + b.WriteString(" '''\n") +} + +// secretUnion returns the sorted, de-duplicated set of secret names a renderer +// references, across the plan-wide union and any scoped phases. Used by both the +// Jenkins `// Required Jenkins credentials:` header and the CircleCI +// `# Required project environment variables:` header. +func secretUnion(p *CIPlan) []string { + seen := make(map[string]bool) + var names []string + add := func(refs []SecretRef) { + for _, s := range refs { + if !seen[s.Name] { + seen[s.Name] = true + names = append(names, s.Name) + } + } + } + add(p.Secrets) + for _, ph := range p.Phases { + if ph.Scoped { + add(ph.Secrets) + } + } + sort.Strings(names) + return names +} + +// sortedSecretNames returns the secret names sorted for deterministic output. +func sortedSecretNames(refs []SecretRef) []string { + names := make([]string, 0, len(refs)) + for _, s := range refs { + names = append(names, s.Name) + } + sort.Strings(names) + return names +} diff --git a/cigen/render_jenkins_test.go b/cigen/render_jenkins_test.go new file mode 100644 index 00000000..37d64753 --- /dev/null +++ b/cigen/render_jenkins_test.go @@ -0,0 +1,125 @@ +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" +) + +func TestRenderJenkins_ConfigDerived(t *testing.T) { + files, err := cigen.RenderJenkins(richCIPlan()) + if err != nil { + t.Fatalf("RenderJenkins: %v", err) + } + content, ok := files["Jenkinsfile"] + if !ok { + t.Fatal("expected Jenkinsfile in output") + } + must := []string{ + "pipeline {", + "// Requires a Jenkins Multibranch Pipeline", // C2 header + "// Required Jenkins credentials: APP_DB_URL, SECRET_ONE, SECRET_TWO", // union, SORTED + "stage('Apply prereq')", + "stage('Apply deploy')", + "environment {", + "when { changeRequest() }", // plan gate + "when { branch 'main' }", // apply gate + "wfctl migrations up", // real runner + "--format json", + "curl --fail --max-time 30 'https://myapp.example.com/healthz'", // smoke + "wfctl infra apply --config 'deploy.yaml' --auto-approve", + } + for _, m := range must { + if !strings.Contains(content, m) { + t.Errorf("Jenkinsfile missing %q\n---\n%s", m, content) + } + } + // Each secret wired individually (robust against header-sort regressions): + // richCIPlan phases are NOT Scoped, so both apply stages use the p.Secrets union. + for _, name := range []string{"APP_DB_URL", "SECRET_ONE", "SECRET_TWO"} { + if !strings.Contains(content, "credentials('"+name+"')") { + t.Errorf("expected credentials('%s') binding", name) + } + } + if !strings.Contains(content, "exit 1") { + t.Error("expected plan-guard exit 1") + } + for _, banned := range []string{"go test ./...", "wfctl deploy --image", "docker build", "docker push", "wfctl ci run --phase migrate"} { + if strings.Contains(content, banned) { + t.Errorf("Jenkinsfile must NOT contain legacy %q", banned) + } + } + if strings.Index(content, "stage('Apply prereq')") > strings.Index(content, "stage('Apply deploy')") { + t.Error("expected Apply prereq stage before Apply deploy stage") + } +} + +func TestRenderJenkins_NilPlan(t *testing.T) { + if _, err := cigen.RenderJenkins(nil); err == nil { + t.Error("expected error for nil plan") + } +} + +// TestRenderJenkins_ScopedPhase locks in the phase.Scoped branch: a scoped phase +// must bind ONLY its own secrets, not the plan-wide union. +func TestRenderJenkins_ScopedPhase(t *testing.T) { + p := &cigen.CIPlan{ + DefaultBranch: "main", + Secrets: []cigen.SecretRef{{Name: "UNION_ONLY"}}, + Phases: []cigen.DeployPhase{ + {Name: "prereq", ConfigPath: "prereq.yaml", Scoped: true, Secrets: []cigen.SecretRef{{Name: "PREREQ_ONLY"}}}, + {Name: "deploy", ConfigPath: "deploy.yaml", Scoped: true, Secrets: []cigen.SecretRef{{Name: "DEPLOY_ONLY"}}}, + }, + } + content := mustJenkins(t, p) + prereq := stageBlock(content, "Apply prereq") + deploy := stageBlock(content, "Apply deploy") + if !strings.Contains(prereq, "credentials('PREREQ_ONLY')") { + t.Errorf("prereq stage must bind PREREQ_ONLY\n%s", prereq) + } + if strings.Contains(prereq, "credentials('DEPLOY_ONLY')") || strings.Contains(prereq, "credentials('UNION_ONLY')") { + t.Errorf("prereq stage must NOT bind other phases' / union secrets\n%s", prereq) + } + if !strings.Contains(deploy, "credentials('DEPLOY_ONLY')") { + t.Errorf("deploy stage must bind DEPLOY_ONLY\n%s", deploy) + } + // Header union spans both scoped phases. + if !strings.Contains(content, "// Required Jenkins credentials: DEPLOY_ONLY, PREREQ_ONLY, UNION_ONLY") { + t.Errorf("expected sorted union header\n%s", content) + } +} + +func mustJenkins(t *testing.T, p *cigen.CIPlan) string { + t.Helper() + files, err := cigen.RenderJenkins(p) + if err != nil { + t.Fatalf("RenderJenkins: %v", err) + } + return files["Jenkinsfile"] +} + +// stageBlock returns the text of the named stage up to the next stage or EOF. +func stageBlock(content, stageName string) string { + start := strings.Index(content, "stage('"+stageName+"')") + if start < 0 { + return "" + } + rest := content[start+1:] + if next := strings.Index(rest, " stage('"); next >= 0 { + return content[start : start+1+next] + } + return content[start:] +} + +func TestRenderJenkins_SinglePhase(t *testing.T) { + p := richCIPlan() + p.Phases = []cigen.DeployPhase{{Name: "deploy", ConfigPath: "deploy.yaml"}} + files, err := cigen.RenderJenkins(p) + if err != nil { + t.Fatalf("RenderJenkins single-phase: %v", err) + } + if !strings.Contains(files["Jenkinsfile"], "stage('Apply deploy')") { + t.Error("expected single Apply deploy stage") + } +} diff --git a/cmd/wfctl/ci.go b/cmd/wfctl/ci.go index d43e55fa..e51e1073 100644 --- a/cmd/wfctl/ci.go +++ b/cmd/wfctl/ci.go @@ -49,7 +49,7 @@ Actions: validate Validate CI config sections Options: - --platform CI platform: github_actions, gitlab_ci (required for generate) + --platform CI platform: github_actions, gitlab_ci, jenkins, circleci (required for generate) --config Workflow config file (default: app.yaml or infra.yaml) --out Output path for generate files (directory, default: .) --from-plan Skip Analyze; load a CIPlan JSON directly @@ -70,7 +70,7 @@ Examples: func runCIGenerate(args []string) error { fs := flag.NewFlagSet("ci generate", flag.ContinueOnError) - platform := fs.String("platform", "", "CI platform: github_actions, gitlab_ci") + platform := fs.String("platform", "", "CI platform: github_actions, gitlab_ci, jenkins, circleci") configFile := fs.String("config", "", "Workflow config file") configFileShort := fs.String("c", "", "Workflow config file (shorthand)") outputDir := fs.String("output", ".", "Output directory") @@ -101,7 +101,7 @@ func runCIGenerate(args []string) error { // When stdin is not a TTY and --platform is also absent, fail clearly. if *platform == "" && !*interactive { if !isatty.IsTerminal(os.Stdin.Fd()) { - return fmt.Errorf("specify --platform for non-interactive generation (github_actions, gitlab_ci)") + return fmt.Errorf("specify --platform for non-interactive generation (github_actions, gitlab_ci, jenkins, circleci)") } // Fall through — wizard will be run after the plan is built. } @@ -162,8 +162,12 @@ func runCIGenerate(args []string) error { files, renderErr = cigen.RenderGitHubActions(plan) case "gitlab_ci": files, renderErr = cigen.RenderGitLabCI(plan) + case "jenkins": + files, renderErr = cigen.RenderJenkins(plan) + case "circleci": + files, renderErr = cigen.RenderCircleCI(plan) default: - return fmt.Errorf("unsupported platform %q (supported: github_actions, gitlab_ci)", resolvedPlatform) + return fmt.Errorf("unsupported platform %q (supported: github_actions, gitlab_ci, jenkins, circleci)", resolvedPlatform) } if renderErr != nil { return renderErr @@ -290,8 +294,12 @@ func generateCIFiles(opts ciOptions) (map[string]string, error) { return generateGitHubActions(opts) case "gitlab_ci": return generateGitLabCI(opts) + case "jenkins": + return generateJenkins(opts) + case "circleci": + return generateCircleCI(opts) default: - return nil, fmt.Errorf("unsupported platform %q (supported: github_actions, gitlab_ci)", opts.Platform) + return nil, fmt.Errorf("unsupported platform %q (supported: github_actions, gitlab_ci, jenkins, circleci)", opts.Platform) } } @@ -307,6 +315,18 @@ func generateGitLabCI(opts ciOptions) (map[string]string, error) { return cigen.RenderGitLabCI(plan) } +// generateJenkins builds a minimal CIPlan from opts and renders a Jenkinsfile. +func generateJenkins(opts ciOptions) (map[string]string, error) { + plan := ciOptionsToPlan(opts) + return cigen.RenderJenkins(plan) +} + +// generateCircleCI builds a minimal CIPlan from opts and renders CircleCI config. +func generateCircleCI(opts ciOptions) (map[string]string, error) { + plan := ciOptionsToPlan(opts) + return cigen.RenderCircleCI(plan) +} + // ciOptionsToPlan converts legacy ciOptions to a minimal CIPlan. func ciOptionsToPlan(opts ciOptions) *cigen.CIPlan { configPath := opts.InfraConfig diff --git a/cmd/wfctl/ci_test.go b/cmd/wfctl/ci_test.go index 8d970a1e..31595961 100644 --- a/cmd/wfctl/ci_test.go +++ b/cmd/wfctl/ci_test.go @@ -569,3 +569,23 @@ func TestRunCIGenerate_NoOverwriteWithoutWrite(t *testing.T) { t.Errorf("expected error to mention --write, got: %v", err) } } + +// TestGenerateCIFiles_JenkinsCircleCI verifies the platform switch dispatches to +// the cigen Jenkins/CircleCI renderers (#804). Content quality is gated by the +// cigen unit tests, not here. +func TestGenerateCIFiles_JenkinsCircleCI(t *testing.T) { + cases := map[string]string{"jenkins": "pipeline {", "circleci": "version: 2.1"} + for plat, marker := range cases { + files, err := generateCIFiles(ciOptions{Platform: plat, InfraConfig: "infra.yaml"}) + if err != nil { + t.Fatalf("%s: %v", plat, err) + } + joined := "" + for _, c := range files { + joined += c + } + if !strings.Contains(joined, marker) { + t.Errorf("%s: expected marker %q in output", plat, marker) + } + } +} diff --git a/cmd/wfctl/ci_wizard.go b/cmd/wfctl/ci_wizard.go index 3e92c21b..5153b31d 100644 --- a/cmd/wfctl/ci_wizard.go +++ b/cmd/wfctl/ci_wizard.go @@ -48,7 +48,7 @@ func applyWizardOverrides(plan *cigen.CIPlan, choices wizardChoices) { } // platformOptions is the ordered list of CI platforms for the wizard. -var platformOptions = []string{"github_actions", "gitlab_ci"} +var platformOptions = []string{"github_actions", "gitlab_ci", "jenkins", "circleci"} // runnerOptions is the ordered list of common runner labels for the wizard. var runnerOptions = []string{"ubuntu-latest", "self-hosted", "other (type below)"} diff --git a/decisions/0044-cigen-renderers-omit-legacy-build-deploy-stages.md b/decisions/0044-cigen-renderers-omit-legacy-build-deploy-stages.md new file mode 100644 index 00000000..e8bd0ffa --- /dev/null +++ b/decisions/0044-cigen-renderers-omit-legacy-build-deploy-stages.md @@ -0,0 +1,53 @@ +# 0044. cigen Jenkins/CircleCI renderers omit the legacy docker-build/deploy stages + +**Status:** Accepted +**Date:** 2026-05-31 +**Decision-makers:** Workflow maintainers, autonomous pipeline +**Related:** `docs/plans/2026-05-31-cigen-jenkins-circleci-design.md`, issue #804 + +## Context + +`cigen` renders a platform-neutral `CIPlan` into CI config. The GitHub Actions +and GitLab CI renderers emit a **plan / per-phase apply / smoke** job set derived +from the CIPlan (secret env wiring, plan-guard, `wfctl migrations up`, smoke). +They deliberately do **not** emit a `docker build`/`docker push`/`wfctl deploy +--image` stage — `CIPlan.Build` exists but no renderer consumes it. + +The legacy Jenkins and CircleCI generators in +`workflow-plugin-ci-generator/internal/platforms/` are `text/template` files that +are NOT config-derived: they hardcode `go test ./...`, `go build ./...`, `docker +build/push`, and `wfctl deploy --image $REGISTRY_IMAGE`, ignoring the app's +secrets union, phases, migrations, smoke, and plugin-install needs. + +Issue #804 asks to make Jenkins/CircleCI config-derived "like GHA/GitLab in +PR #18" and to retire the templates. This forces a choice about the hardcoded +docker-build/deploy stages the legacy Jenkins/CircleCI templates carried that the +GHA/GitLab renderers never had. + +## Decision + +The new `cigen.RenderJenkins` / `cigen.RenderCircleCI` renderers emit the **same +logical job set as the GHA/GitLab renderers** — plan / per-phase apply (secret +env + plan-guard + last-phase migrations) / smoke — and **do not** emit a +docker-build/push or `wfctl deploy --image` stage. `CIPlan.Build` remains unused +by all four renderers; no `Analyze` change and no new CIPlan field is introduced. + +We reject keeping a docker-build/deploy stage (config-gated on `CIPlan.Build`) in +Jenkins/CircleCI, because that would either (a) make the four renderers diverge +in job set, or (b) require teaching the GHA/GitLab renderers the same stage for +parity — both contradict #804's "mechanical emitters from the existing CIPlan, +no Analyze change" framing and re-introduce non-config-derived, app-shape-guessing +output. + +## Consequences + +All four platforms now render the **same** config-derived plan from one CIPlan, +so output is consistent and `--from-plan`/`--diff` behave identically across +platforms. This is a **behavior change** for any consumer that relied on the +legacy Jenkins/CircleCI `docker build`/`wfctl deploy` stages: those stages are +removed, surfaced via the plugin minor bump (v0.2.0) and release notes. Building +and pushing an application image is orthogonal to infra CI generation and, if a +user needs it, belongs in app-specific CI the user authors — not in cigen's +infra-deployment output. If config-derived image build/deploy is ever wanted, it +must be added uniformly to all four renderers behind a populated `CIPlan.Build`, +in a separate design. diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 64fbef69..bb92ffa2 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2115,7 +2115,7 @@ Generated files: ### `ci generate` -Analyze a workflow config with the `cigen` engine (config → `CIPlan` → render) and write CI configuration files for the target platform. The engine derives: +Analyze a workflow config with the `cigen` engine (config → `CIPlan` → render) and write CI configuration files for the target platform. All four platforms (`github_actions`, `gitlab_ci`, `jenkins`, `circleci`) are config-derived from the same `CIPlan`. The engine derives: - A `secrets: env:` block of `${{ secrets.NAME }}` references from declared `secrets.entries` - A `wfctl plugin install` step when plugin or infra modules are detected @@ -2132,7 +2132,7 @@ wfctl ci generate [options] | Flag | Default | Description | |------|---------|-------------| -| `--platform` | _(wizard)_ | CI platform: `github_actions`, `gitlab_ci`. Required in non-interactive mode. | +| `--platform` | _(wizard)_ | CI platform: `github_actions`, `gitlab_ci`, `jenkins`, `circleci`. Required in non-interactive mode. | | `-c`, `--config` | `app.yaml` or `infra.yaml` | Workflow config file to analyze | | `--output`, `--out` | `.` | Output directory for generated files | | `--runner` | `ubuntu-latest` | Runner label (GitHub Actions only) | @@ -2162,6 +2162,13 @@ wfctl ci generate -c deploy.yaml --platform github_actions --diff --exit-code # Render from a pre-built plan (no re-analysis) wfctl ci generate --platform github_actions --from-plan plan.json --write + +# Jenkins (declarative Jenkinsfile) — requires a Jenkins Multibranch Pipeline job; +# the generated Jenkinsfile carries a header comment listing the required credentials +wfctl ci generate -c deploy.yaml --platform jenkins --write + +# CircleCI (.circleci/config.yml) — references project-level env vars (auto-injected) +wfctl ci generate -c deploy.yaml --platform circleci --write ``` --- diff --git a/docs/plans/2026-05-31-cigen-jenkins-circleci-design.md b/docs/plans/2026-05-31-cigen-jenkins-circleci-design.md new file mode 100644 index 00000000..ffb1cf17 --- /dev/null +++ b/docs/plans/2026-05-31-cigen-jenkins-circleci-design.md @@ -0,0 +1,389 @@ +# cigen: config-derived Jenkins + CircleCI — Design + +**Status:** Approved (autonomous — user pre-authorized full-pipeline execution) +**Date:** 2026-05-31 +**Issue:** https://github.com/GoCodeAlone/workflow/issues/804 +**Repos touched:** workflow (cigen + wfctl), workflow-plugin-ci-generator (rewire), workflow-scenarios (proof) +**Adversarial review:** cycle 1 = FAIL (1C/3I/3m, all resolved); cycle 2 = FAIL +(1C/2I/1m new, all resolved this revision): C2 Jenkins Multibranch requirement + +header comment + accepted standard-pipeline consequence; I4/I5 precise PR2 test +rewrite (replace `_TemplateUnchanged` with `_CigenMarkers`, delete +`staticGenerator`/`registerTestGenerator`, unit-test `validateRelativeOutputPath` +directly, integration_test.go drives `ExecuteCIGenerate` for jenkins+circleci); +m4 Jenkins-no-PR-comment recorded in Non-Goals. cycle 3 = PASS (converged; lone +Minor — integration_test.go mock→real ExecuteCIGenerate — applied). + +## Problem + +`workflow/cigen` does config-derived `analyze → CIPlan → render` for **GitHub +Actions** and **GitLab CI** only. Jenkins and CircleCI are still served by +**legacy text/template generators** that ignore the CIPlan entirely: + +- In `workflow-plugin-ci-generator/internal/platforms/{jenkins,circleci}.go`, + the templates hardcode `go test ./...`, `go build ./...`, `docker build/push`, + `wfctl deploy --image $REGISTRY_IMAGE` — none of which is derived from the + app's secrets union, phases, migrations, smoke, or plugin-install needs. +- `wfctl ci generate --platform jenkins|circleci` is **unsupported** today + (`cmd/wfctl/ci.go` only switches on `github_actions`/`gitlab_ci`; the wizard's + `platformOptions` lists only those two). + +So the same CIPlan that produces correct GHA/GitLab output cannot produce +Jenkins/CircleCI output, and two of four platforms emit non-config-derived CI. + +## Goals + +1. `cigen.RenderJenkins(*CIPlan)` + `cigen.RenderCircleCI(*CIPlan)` — mechanical + emitters from the **existing** CIPlan, structurally mirroring the GHA/GitLab + renderers (plan / per-phase apply with secret env + plan-guard + migrations / + smoke). No `Analyze` change; no new CIPlan fields. +2. `wfctl ci generate --platform jenkins|circleci` routes through cigen (render + switch + wizard `platformOptions` + usage text). +3. `workflow-plugin-ci-generator` routes `step.ci_generate` jenkins/circleci + through cigen (extend the existing GHA/GitLab cigen branch) and **removes the + legacy template generators**. +4. Golden/structural tests for both renderers (parity with `render_gha_test.go` + / `render_gitlab_test.go`). +5. **Proof in workflow-scenarios**: a scenario that *runs* `wfctl ci generate + --platform jenkins` and `--platform circleci` against a real config and + asserts the output is config-derived (secret env wiring, `wfctl migrations + up`, smoke, plan-guard) and free of the legacy hardcoded stages. + +## Non-Goals + +- No `Analyze` change, no new CIPlan field, no docker-build/image/deploy stage. + **The config-derived renderers emit the same job set as GHA/GitLab — plan / + apply / smoke — which deliberately does NOT include `docker build`/`wfctl + deploy`.** Those legacy stages are exactly the non-config-derived surface this + issue retires. (CIPlan.Build exists but is unused by all renderers today; that + stays true — see ADR.) +- No change to GHA/GitLab renderers' output. +- No new wfctl `ci` subcommands; `--from-plan`, `--diff`, `--write` all work + unchanged for the new platforms because they sit above the render switch. +- No live Jenkins/CircleCI execution in the proof (no Jenkins server); the proof + is generate-and-assert-output, same posture as scenario 77. +- No Jenkins PR-comment of the plan (GHA-only feature; Jenkins has no native + equivalent — plan goes to the build log). Accepted delta, not a regression. +- No fix for the GitLab renderer's pre-existing missing plan-guard/scoped-secrets + (tracked as a Follow-up; #804 is jenkins/circleci-scoped). + +## Global Design Guidance + +`Guidance: no docs/design-guidance.md in workflow; canon = CLAUDE.md (cigen +conventions, "config-derived" principle) + the cigen renderer precedent +(render_gha.go / render_gitlab.go) + ADR series in decisions/.` + +| guidance | design response | +|---|---| +| Config-derived over templated | Both new renderers read only CIPlan; zero hardcoded app commands. | +| Mirror existing renderer precedent | Job structure, secret-scoping (`phase.Scoped`), plan-guard, `migrationsUpCommand` are reused verbatim from the GHA/GitLab renderers — no new patterns. | +| `wfctl migrations up` is the real runner | Both renderers call the shared `migrationsUpCommand` helper (already correct: `wfctl migrations up … --format json`, never `wfctl ci run --phase migrate`). | +| DRY across renderers | Shared helpers (`migrationsUpCommand`, secret-source branching on `phase.Scoped`) are reused, not re-implemented per platform. | +| Record non-trivial trade-offs | ADR: "config-derived renderers omit the legacy docker-build/deploy stages." | + +## Approach Options + +| option | summary | trade-off | +|---|---|---| +| **Recommended: mirror GHA/GitLab job set, drop legacy docker stages** | Jenkins declarative pipeline + CircleCI 2.1, emitting plan/apply/smoke from CIPlan, secret env wiring per platform idiom. | Smallest, most consistent; all four platforms render the same logical plan. Behavior change for anyone who relied on the legacy Jenkins `docker build`/`wfctl deploy` — but those were never config-derived and are the issue's explicit target. | +| Keep a docker-build stage in Jenkins/CircleCI (gated on `CIPlan.Build`) | Preserve the legacy build/deploy behavior, config-gate it. | Requires teaching the GHA/GitLab renderers the same (for parity) OR diverging the four renderers — both out of scope and contradict "no Analyze change / mechanical emit". Rejected. | +| Two separate cross-repo features (renderers now, plugin later) | Ship cigen renderers + wfctl first, defer plugin rewire. | The issue's acceptance #2 explicitly requires the plugin rewire + template removal; deferring leaves the issue half-done. Rejected — done as one cascade. | + +## Design + +### Renderer output mapping (CIPlan → platform) + +**Authoritative precedent is `render_gha.go`**, NOT GitLab. The GHA renderer is +the only existing renderer that implements **plan-guard** (`writeApplyJob` lines +202–211) and **per-phase secret scoping** (branch on `phase.Scoped`, line 181). +`render_gitlab.go` omits both — that is a pre-existing GitLab gap (see Backport / +Follow-up below), not a precedent to copy. Both new renderers implement the full +GHA feature set: plan / per-phase apply (scoped secrets + plan-guard + last-phase +migrations) / smoke. + +Logical job/stage set (same as GHA): + +- **plan** (one per phase): `wfctl infra plan --config `, + gated on PR / merge-request / `changeRequest()`. +- **apply** (one per phase): + - secret env sourced by branching on `phase.Scoped` (NOT `len`): scoped phase + uses `phase.Secrets`; unscoped falls back to `p.Secrets`. + - **plan-guard** (when `p.PlanGuard`): `wfctl infra plan … | tee`, grep for + replace/destroy, `exit 1` — no `|| true`. (Carried from GHA.) + - **migrations** (last phase only, when `p.Migrations != nil`): the shared + `migrationsUpCommand(configPath, p.Migrations.Env)`. + - `wfctl infra apply --config --auto-approve`. + - apply gated to the default branch (+ manual dispatch where supported). +- **smoke** (when `p.Smoke != nil`): `curl --fail --max-time 30 `, + ordered after the last apply. +- **plugin install** (when `p.PluginInstall`): `wfctl plugin install --config + ` before plan/apply. +- wfctl is installed/pinned per platform idiom using `p.WfctlVersion`. + +#### Jenkins declarative structure (the real structural difference — I1) + +A declarative Jenkinsfile has a **single linear `stages {}` block** — there is no +GHA/GitLab independent-job graph and no `needs:`. The PR-vs-push split and +multi-phase ordering map onto **sequential stages gated by `when`**, with +**per-stage `environment {}`** for per-phase secret scoping: + +``` +// Generated by wfctl ci generate. Requires a Jenkins Multibranch Pipeline job: +// plan stages gate on changeRequest() (PR branches); apply/smoke gate on the +// default branch. In a standard single-branch job, changeRequest() is always +// false and plan stages will not run. +// Required Jenkins credentials: SECRET_ONE, SECRET_TWO, APP_DB_URL +pipeline { + agent { label 'linux' } + stages { + stage('Plan ') { when { changeRequest() } steps { sh 'wfctl infra plan --config ...' } } // one per phase + stage('Apply ') { // one per phase, in order + when { branch '' } + environment { SECRET = credentials('SECRET'); ... } // per-phase scoped secrets + steps { + sh '' // when PlanGuard + sh '' // last phase only, when Migrations + sh 'wfctl infra apply --config --auto-approve' + } + } + stage('Smoke') { when { branch '' } steps { sh "curl --fail --max-time 30 ''" } } // when Smoke + } +} +``` + +**Multibranch requirement (C2):** this structure is correct **only in a Jenkins +Multibranch Pipeline** — `changeRequest()` is true on PR branches and false on the +default branch (plan runs on PRs, apply runs on main). In a standard single-branch +job pointed at main, `changeRequest()` is always false and plan stages silently +no-op. The renderer therefore emits the `// Requires a Jenkins Multibranch +Pipeline` header comment above (mirroring the legacy template's same +`changeRequest()` assumption, now made explicit). Manual "Build Now" on the +Multibranch main branch satisfies `branch ''`, so no separate dispatch +gate is needed. Multi-phase chaining is **implicit stage ordering** (prereq apply +stage precedes deploy apply stage); no `needs` keyword exists or is needed. Each +apply stage's own `environment {}` gives the per-phase secret scope GHA gets via +per-job `env:`. + +**Lost feature vs GHA (m4):** GHA's plan job posts the plan as a PR comment +(`actions/github-script`). Jenkins has no native equivalent; the Jenkins plan +output goes to the build log only. This is an accepted delta (recorded in +Non-Goals), not a regression of config-derived behavior. + +#### CircleCI structure + +CircleCI 2.1 DOES have independent jobs + a `workflows:` graph with `requires:` +(closest to GHA). plan/apply/smoke are jobs; the `workflows` block orders them +with `requires:` and `filters.branches`. Multi-phase = `apply-prereq` → +`apply-deploy` via `requires:`. + +**Platform-specific secret idiom** (the real per-platform difference): + +- **Jenkins** (declarative): `environment { NAME = credentials('NAME') }` inside + each apply stage. `credentials('NAME')` binds a credential **pre-created in the + Jenkins credential store with id `NAME`** — unlike GHA `${{secrets.NAME}}` / + GitLab auto-injected vars, an absent credential fails the build at runtime. To + surface this operator precondition (I3), the Jenkins renderer emits a header + comment `// Required Jenkins credentials: NAME1, NAME2, ...` listing every + secret the file binds, plus the existing `Warnings` for non-`^[A-Z0-9_]+$` + names. No plaintext secret value is ever written. +- **CircleCI**: project-level env vars are auto-injected into every job (like + GitLab), so the renderer does **not** re-declare `NAME: $NAME` no-ops; it only + references them. (Mirrors `render_gitlab.go`'s `NoRedundantSecretVars` rule. + CircleCI *contexts* are opt-in and orthogonal — a user adds `context:` manually + if needed, same as GitLab.) + +### New/changed files + +**PR1 — workflow** (`feat/cigen-jenkins-circleci-804`): +- `cigen/render_jenkins.go` — `RenderJenkins(*CIPlan) (map[string]string, error)` + → `{"Jenkinsfile": …}`. +- `cigen/render_circleci.go` — `RenderCircleCI(*CIPlan) (map[string]string, + error)` → `{".circleci/config.yml": …}`. +- `cigen/render_jenkins_test.go`, `cigen/render_circleci_test.go` — reuse the + shared `richCIPlan()` helper; assert: + - **Jenkins**: structural greps — `pipeline {`, per-phase `stage('Apply …')`, + `environment {`, `credentials('APP_DB_URL')` (and other secret names), the + `// Required Jenkins credentials:` header lists every secret, `wfctl + migrations up`, plan-guard grep + `exit 1`, smoke `curl`, two-phase ordering + (Apply prereq stage appears before Apply deploy stage), nil-plan error, a + **single-phase** plan renders without panic. + - **CircleCI**: `yaml.Unmarshal` succeeds AND structural assertions — + `version: 2.1`, `workflows:` present, job names under workflows include plan + + apply variants, `requires:` references match job names (NOT GHA `needs:`), + no redundant `NAME: $NAME` secret re-declares, `wfctl migrations up`, smoke, + plan-guard, two-phase `requires:` chain, nil-plan error, single-phase case. + - **Both**: **absence** of legacy `go test ./...` / `wfctl deploy --image` / + `docker build` / `docker push` (proves the docker-stage drop, ADR 0044). +- `cmd/wfctl/ci.go` — add `case "jenkins"` / `case "circleci"` to the render + switch (line ~160) AND to the legacy `generateCIFiles` switch (line ~288); + update BOTH the usage text (lines ~52/73/104) and the two + `"unsupported platform %q (supported: github_actions, gitlab_ci)"` error + strings (lines ~166, ~294) to list all four platforms. +- `cmd/wfctl/ci_wizard.go` — add `jenkins`, `circleci` to `platformOptions`. +- `DOCUMENTATION.md` / `docs/WFCTL.md` — note four-platform support. +- Version bump → **v0.68.0** (minor: new public renderers + CLI platforms). + +**PR2 — workflow-plugin-ci-generator** (`feat/cigen-jenkins-circleci-804`): +- `go.mod` — bump `github.com/GoCodeAlone/workflow` v0.67.0 → **v0.68.0**; `go + mod tidy`. +- `internal/generator.go` — extend the cigen branch to all four platforms + (`case PlatformGitHubActions, PlatformGitLabCI, PlatformJenkins, + PlatformCircleCI:` with a 4-way render switch); delete the `registry` map, the + `Generator` interface, and the legacy `default:` branch. +- **Delete the entire `internal/platforms/` package** — after rewire all four + constructors are unused. `github_actions.go`/`gitlab_ci.go` are no longer called + from the production path (`generator.go` routes GHA/GitLab through cigen since + #18); their own `*_test.go` files are the only remaining referees. PR2 deletes + all four generators **and their tests** — this is intentional (retiring all four + template generators, not only jenkins/circleci), a slightly broader-than-#804 + cleanup justified because leaving two dead files + dead tests is worse. +- **Test rewrite (I4/I5) — explicit, because PR2 deletes `registry`/`Generator`/ + `platforms.Options`:** + - `internal/generator_test.go`: + - **Replace** `TestExecuteCIGenerateJenkins_TemplateUnchanged` (line ~267) and + `TestExecuteCIGenerateCircleCI_TemplateUnchanged` (~306) with + `..._CigenMarkers` tests mirroring the existing GHA/GitLab marker tests + (~66/~127): assert the written file is config-derived (secret wiring, + `wfctl migrations up`, smoke, plan-guard) and free of `go test`/`wfctl + deploy --image`. + - **Delete** the `staticGenerator` struct (~411) and `registerTestGenerator` + helper (~421) — they use the deleted `registry`/`Generator`/ + `platforms.Options`. `TestExecuteCIGenerateRejectsUnsafeGeneratedPath` + (~356) and `TestExecuteCIGenerateSortsFilesWritten` (~377) used them to + inject paths via the registry seam; rewrite both to test the surviving + package functions **directly**: a focused unit test of + `validateRelativeOutputPath` (preserves the path-safety guarantee without + the registry seam) and a sort assertion over a real multi-file cigen render + (preserves the file-ordering guarantee). + - `integration_test.go`: this is the **plugin-path proof for acceptance #2** — + it must drive `ExecuteCIGenerate` end-to-end. NOTE: the current + `TestIntegration_CIGenerateWithInput` (circleci, line ~140) uses + `wftest.MockStep`, which cannot write/assert real file content — PR2 must + **replace the mock with a direct `ExecuteCIGenerate` call** that writes to a + temp dir, and add the jenkins equivalent, so both assert the written + `Jenkinsfile` / `.circleci/config.yml` are config-derived (secret wiring, + `wfctl migrations up`, smoke, plan-guard) and free of legacy `go test`/`wfctl + deploy --image`. +- Version bump plugin → **v0.2.0** (behavior change: jenkins/circleci now + config-derived). + +**PR3 — workflow-scenarios** (`feat/cigen-jenkins-circleci-proof-804`): +- `scenarios/97-ci-generate-jenkins-circleci/` — `scenario.yaml`, `README.md`, + `config/app.yaml` (real config: secrets, `ci.migrations`, + `infra.container_service` with health_check+PRIMARY domain → smoke, a + `protected: true` module → plan-guard, an `infra.*`/`iac.*` module → + plugin-install), `test/run.sh`. +- `test/run.sh`: locate wfctl (`WFCTL_BIN`/`$WORKFLOW_REPO/bin/wfctl`); run + `wfctl ci generate --platform jenkins --config --output-dir + --write` and `--platform circleci`; assert the generated `Jenkinsfile` and + `.circleci/config.yml` each contain: a config secret name, `wfctl migrations + up`, the smoke URL, plan-guard grep, `wfctl infra apply`; and do NOT contain + `go test ./...` / `wfctl deploy --image`. Additionally run `wfctl validate` on + a small `step.ci_generate` config with `platform: jenkins` and `circleci` to + prove the plugin-step config shape is accepted (config-shape half of acceptance + #2; the behavior half is PR2's `integration_test.go`). Skip cleanly if wfctl + absent. +- Register in `scenarios.json` (next free id **97**). + +### Cross-repo sequencing (hard dependency) + +PR2 imports `cigen.RenderJenkins`/`RenderCircleCI`, which exist only after PR1 +merges **and** workflow is tagged v0.68.0. So: + +1. PR1 merges to workflow/main → tag **v0.68.0**. +2. PR2 bumps the plugin's go.mod to v0.68.0 (now resolvable) → merges → plugin + v0.2.0. +3. PR3 builds wfctl from workflow main (post-PR1) → scenario passes in CI. + +For **honest local proof now** (demonstration-fidelity): build wfctl from the +PR1 branch and run scenario 97 against it (`WFCTL_BIN=`), capturing +the real generated Jenkinsfile/CircleCI output as evidence in the PR. The proof +executes the real `wfctl ci generate`, not a reimplementation. + +## Security Review + +- **Secrets:** the renderers emit secret *references*, never values. Jenkins uses + `credentials('NAME')` (Jenkins credential store), CircleCI references + auto-injected project env vars — neither writes a secret value into the file. + Same posture as GHA (`${{ secrets.NAME }}`) / GitLab (auto-injected). +- **Plan-guard carried from GHA:** the destructive-plan guard (`exit 1`, no + `|| true`) is implemented in both new renderers, modeled on the **GHA** + renderer (the GitLab renderer lacks it — see Follow-up). A protected resource + still blocks apply in Jenkins/CircleCI output. +- **GitLab plan-guard gap (pre-existing, out of scope):** `render_gitlab.go` + emits no plan-guard and no scoped-secret branch. This PR does **not** fix that + (it is #804-orthogonal) but records it as a follow-up so GitLab reaches parity + later. The new renderers do NOT inherit GitLab's gap. +- **No new network/exec surface:** renderers are pure string builders; the plugin + path already writes files under a validated relative output dir + (`validateRelativeOutputPath`). +- **Path safety:** config paths come from CIPlan (already relativized by Analyze + / aliased by the plugin); the renderers embed them verbatim like GHA/GitLab. + +## Infrastructure Impact + +None at deploy time. This generates CI config files; it does not create/destroy +cloud resources. The *generated* pipelines run `wfctl infra apply` — but that is +the user's pipeline, unchanged in intent from the GHA/GitLab output. Version +pins (workflow v0.68.0; plugin v0.2.0) are runtime-component bumps → version-skew +audit + rollback notes apply (see Rollback). + +## Multi-Component Validation + +- **cigen ↔ wfctl:** `wfctl ci generate --platform jenkins|circleci` exercises + the real Analyze→Render path end-to-end (PR1 golden tests + PR3 scenario run a + real binary, not a mock). +- **cigen ↔ plugin:** `step.ci_generate` for jenkins/circleci routes through the + same `cigen.Render*`; PR2 integration test asserts config-derived output from + the plugin entry point. +- **Real boundary in the proof:** PR3 runs the built wfctl and asserts on the + actual emitted files (existence + behavior), not on the config — the + Existence/runtime-validity discipline (autodev #55) applied here. +- **Acceptance-coverage split (I2):** acceptance #1 (`wfctl ci generate + --platform jenkins|circleci`, CLI) is proven by PR1 golden tests + the PR3 + scenario behavior run. Acceptance #2 (`step.ci_generate` plugin route) is + proven by **PR2's `integration_test.go`**, which drives `ExecuteCIGenerate` + (the real plugin entry point) for both platforms and asserts config-derived + output — a distinct code path from the CLI. The PR3 scenario additionally + includes a `wfctl validate` check that a `step.ci_generate` config with + `platform: jenkins|circleci` is accepted (config-shape proof, like scenario + 77). No acceptance criterion is left unproven. + +## Assumptions + +| id | assumption | challenge | fallback | +|---|---|---|---| +| A1 | Dropping the legacy docker-build/deploy stages is acceptable | A Jenkins/CircleCI user may rely on the old build/deploy | Documented behavior change + plugin minor bump v0.2.0 + ADR; GHA/GitLab never emitted these, so this is parity, not loss of a config-derived feature. | +| A2 | `credentials('NAME')` is the right Jenkins secret idiom for arbitrary secret names | Unlike GHA/GitLab, `credentials('NAME')` requires a Jenkins credential **pre-created with id `NAME`**; an absent one fails the build at runtime (harder to debug) | Emit string credentials by default (matches env-var usage); the Jenkins renderer emits a `// Required Jenkins credentials: …` header listing every bound secret so the operator knows what to pre-create; existing `Warnings` for non-`^[A-Z0-9_]+$` names retained. | +| A3 | workflow can be tagged v0.68.0 before the plugin bump | Tag/release cadence | PR1 merge + tag is a prerequisite gate for PR2; sequenced explicitly. | +| A4 | `github_actions.go`/`gitlab_ci.go` in the plugin are no longer on the production path | They might be referenced indirectly | Verified: `generator.go` registry holds only jenkins/circleci; GHA/GitLab route through cigen since #18; only their own `*_test.go` reference the dead constructors. Whole-package deletion (incl. those tests) is intentional. | +| A5 | CircleCI auto-injects project env vars into jobs (like GitLab) | Contexts vs project env differences | Reference-only (no redeclare) is the safe subset; if a user uses CircleCI contexts they add `context:` manually — same as GitLab's model. | + +## Rollback + +- **PR1 (workflow):** revert the PR; `RenderJenkins`/`RenderCircleCI` are + additive (new files + additive switch cases) — reverting restores three-of-four + platform support with no migration. Do not tag v0.68.0 if reverted. +- **PR2 (plugin):** revert restores the template generators and the v0.67.0 pin. + Because PR2 deletes the `platforms` package, rollback = `git revert` (restores + the files). Rollback note per task. +- **PR3 (scenarios):** revert removes scenario 97; no runtime impact. +- **Version pins:** workflow v0.68.0 / plugin v0.2.0 — to roll back, pin plugin + to v0.1.6 + workflow consumers to v0.67.0 and rebuild. Version-skew audit at + finish: plugin's workflow pin must equal the freshly tagged v0.68.0 (no lag). + +## Follow-ups (out of scope for #804) + +- **GitLab plan-guard + scoped-secret parity:** `render_gitlab.go` lacks both the + destructive-plan guard and the `phase.Scoped` secret branch that GHA (and now + Jenkins/CircleCI) implement. File a follow-up issue to bring GitLab to parity. + Not fixed here to keep #804 scoped to jenkins/circleci. + +## Self-Challenge + +- **Simplest alternative:** just add the 2 renderers + wfctl switch, skip the + plugin rewire. Rejected — issue acceptance #2 mandates the plugin rewire + + template removal; skipping leaves the issue half-done. +- **Most fragile assumption:** A1 (dropping docker stages). Mitigated by ADR + + the fact that config-derived parity with GHA/GitLab is the explicit ask. +- **YAGNI sweep:** no new CIPlan field, no docker stage, no new subcommand, no + per-platform Analyze branch — all rejected as surface the issue didn't ask for. diff --git a/docs/plans/2026-05-31-cigen-jenkins-circleci.md b/docs/plans/2026-05-31-cigen-jenkins-circleci.md new file mode 100644 index 00000000..78a61831 --- /dev/null +++ b/docs/plans/2026-05-31-cigen-jenkins-circleci.md @@ -0,0 +1,668 @@ +# cigen Jenkins + CircleCI Implementation Plan + +> **For the implementing agent:** REQUIRED SUB-SKILL: Use autodev:executing-plans to implement this plan task-by-task. + +**Goal:** Add config-derived `cigen.RenderJenkins` / `cigen.RenderCircleCI`, wire them into `wfctl ci generate` and the ci-generator plugin (retiring the legacy template generators), and prove it via a workflow-scenarios behavior scenario. + +**Architecture:** Two new mechanical renderers in `workflow/cigen` that emit the same plan/apply/smoke job set as the GHA renderer (authoritative precedent) from the existing CIPlan — Jenkins declarative pipeline + CircleCI 2.1. `wfctl ci generate` + the plugin route all four platforms through cigen. A workflow-scenarios scenario runs the real `wfctl ci generate` and asserts config-derived output. Cross-repo: workflow tags v0.68.0 → plugin bumps the pin + rewires → scenario builds wfctl from workflow main. + +**Tech Stack:** Go (stdlib `strings`/`fmt` string builders, `gopkg.in/yaml.v3` in tests); bash scenario harness. + +**Base branch:** main (each repo) + +--- + +## Scope Manifest + +**PR Count:** 3 +**Tasks:** 9 +**Estimated Lines of Change:** ~700 (informational; not enforced) + +**Out of scope:** +- Any `cigen.Analyze` change or new CIPlan field (renderers consume the existing plan). +- Any docker-build/push/`wfctl deploy --image` stage (ADR 0044 — the legacy non-config-derived stages are retired, not ported). +- Any change to GHA/GitLab renderer output. +- Fixing the GitLab renderer's pre-existing missing plan-guard/scoped-secrets (Follow-up). +- Jenkins PR-comment of the plan (no native equivalent; plan goes to build log). +- Live Jenkins/CircleCI server execution in the proof (generate-and-assert posture). + +**PR Grouping:** + +| PR # | Title | Tasks | Branch | +|------|-------|-------|--------| +| 1 | feat(cigen): config-derived Jenkins + CircleCI renderers + wfctl four-platform support (#804) | Task 1, Task 2, Task 3, Task 4 | workflow: feat/cigen-jenkins-circleci-804 | +| 2 | feat: route jenkins/circleci through cigen, retire template generators (#804) | Task 5, Task 6, Task 7 | workflow-plugin-ci-generator: feat/cigen-jenkins-circleci-804 | +| 3 | test(scenario-97): config-derived jenkins/circleci CI generation proof (#804) | Task 8, Task 9 | workflow-scenarios: feat/cigen-jenkins-circleci-proof-804 | + +**Status:** Locked 2026-05-31T22:52:06Z + +--- + +## Project Design Guidance + +`Guidance: no docs/design-guidance.md in workflow; canon = CLAUDE.md cigen +conventions + render_gha.go precedent + ADR 0044.` Mapping: +- Config-derived over templated → renderers read only CIPlan (Tasks 1,2); legacy + templates deleted (Task 6). +- Mirror GHA precedent → Tasks 1,2 reuse `migrationsUpCommand` + `phase.Scoped` + branch + plan-guard from `render_gha.go`. +- `wfctl migrations up` real runner → both renderers call `migrationsUpCommand` + (asserted absent of `wfctl ci run --phase migrate` in tests). +- Record trade-offs → ADR 0044 (committed with the design). + +**Cross-repo execution order (hard gate):** PR1 merges to workflow/main AND is +tagged **v0.68.0** before PR2's `go.mod` bump resolves. PR3 builds wfctl from the +PR1 branch/main. Execute PR1 fully (merge + tag) before PR2; PR3 after PR1. + +--- + +### Task 1: cigen.RenderJenkins + +**Files:** +- Create: `cigen/render_jenkins.go` +- Create: `cigen/render_jenkins_test.go` + +**Step 1: Write the failing test** (`cigen/render_jenkins_test.go`, `package cigen_test`, reuse the shared `richCIPlan()` from `render_gha_test.go`): + +```go +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" +) + +func TestRenderJenkins_ConfigDerived(t *testing.T) { + files, err := cigen.RenderJenkins(richCIPlan()) + if err != nil { + t.Fatalf("RenderJenkins: %v", err) + } + content, ok := files["Jenkinsfile"] + if !ok { + t.Fatal("expected Jenkinsfile in output") + } + must := []string{ + "pipeline {", + "// Requires a Jenkins Multibranch Pipeline", // C2 header + "// Required Jenkins credentials: APP_DB_URL, SECRET_ONE, SECRET_TWO", // union, SORTED — renderer MUST sort + "stage('Apply prereq')", + "stage('Apply deploy')", + "environment {", + "when { changeRequest() }", // plan gate + "when { branch 'main' }", // apply gate + "wfctl migrations up", // real runner + "--format json", + "curl --fail --max-time 30 'https://myapp.example.com/healthz'", // smoke + "wfctl infra apply --config 'deploy.yaml' --auto-approve", + } + for _, m := range must { + if !strings.Contains(content, m) { + t.Errorf("Jenkinsfile missing %q\n---\n%s", m, content) + } + } + // Each secret wired individually (robust against header-sort-order regressions): + // richCIPlan phases are NOT Scoped, so both apply stages use the p.Secrets union. + for _, name := range []string{"APP_DB_URL", "SECRET_ONE", "SECRET_TWO"} { + if !strings.Contains(content, "credentials('"+name+"')") { + t.Errorf("expected credentials('%s') binding", name) + } + } + // plan-guard present + if !strings.Contains(content, "exit 1") { + t.Error("expected plan-guard exit 1") + } + // legacy non-config-derived stages must be ABSENT (ADR 0044) + for _, banned := range []string{"go test ./...", "wfctl deploy --image", "docker build", "docker push", "wfctl ci run --phase migrate"} { + if strings.Contains(content, banned) { + t.Errorf("Jenkinsfile must NOT contain legacy %q", banned) + } + } + // apply-prereq stage appears before apply-deploy stage (ordering) + if strings.Index(content, "stage('Apply prereq')") > strings.Index(content, "stage('Apply deploy')") { + t.Error("expected Apply prereq stage before Apply deploy stage") + } +} + +func TestRenderJenkins_NilPlan(t *testing.T) { + if _, err := cigen.RenderJenkins(nil); err == nil { + t.Error("expected error for nil plan") + } +} + +func TestRenderJenkins_SinglePhase(t *testing.T) { + p := richCIPlan() + p.Phases = []cigen.DeployPhase{{Name: "deploy", ConfigPath: "deploy.yaml"}} + files, err := cigen.RenderJenkins(p) + if err != nil { + t.Fatalf("RenderJenkins single-phase: %v", err) + } + if !strings.Contains(files["Jenkinsfile"], "stage('Apply deploy')") { + t.Error("expected single Apply deploy stage") + } +} +``` + +**Step 2: Run → FAIL** (`undefined: cigen.RenderJenkins`). +Run: `cd /Users/jon/workspace/workflow && GOWORK=off go test ./cigen/ -run TestRenderJenkins 2>&1 | tail` +Expected: compile error / FAIL. + +**Step 3: Implement** (`cigen/render_jenkins.go`) — declarative pipeline, single +linear `stages{}`, per-phase plan stage gated on `changeRequest()`, per-phase +apply stage gated on `branch ''` with per-stage `environment{}` scoped +secrets (branch on `phase.Scoped`), plan-guard (when `p.PlanGuard`), migrations on +last phase via shared `migrationsUpCommand`, smoke stage. Header comments: +Multibranch requirement + sorted union of required credentials. Install wfctl via +`go install …@` inside each stage's steps; pipeline-level `environment { +PATH = "${HOME}/go/bin:${PATH}" }`. Reuse `migrationsUpCommand` (already in +`render_gha.go`). Sort the credentials union for determinism. (Full code authored +in execution to satisfy the assertions above; mirror `render_gha.go`'s +`writeApplyJob` secret/plan-guard/migrations logic.) + +**Step 4: Run → PASS** +Run: `GOWORK=off go test ./cigen/ -run TestRenderJenkins -v 2>&1 | tail -20` +Expected: `PASS` (all three tests). + +**Step 5: Commit** +```bash +git add cigen/render_jenkins.go cigen/render_jenkins_test.go +git commit -m "feat(cigen): config-derived RenderJenkins (#804)" +``` +Rollback: revert commit — additive new files, no migration. + +--- + +### Task 2: cigen.RenderCircleCI + +**Files:** +- Create: `cigen/render_circleci.go` +- Create: `cigen/render_circleci_test.go` + +**Step 1: Write the failing test** (mirror `render_gitlab_test.go` structural depth + YAML validity): + +```go +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" + "gopkg.in/yaml.v3" +) + +func TestRenderCircleCI_ValidYAMLAndStructure(t *testing.T) { + files, err := cigen.RenderCircleCI(richCIPlan()) + if err != nil { + t.Fatalf("RenderCircleCI: %v", err) + } + content := files[".circleci/config.yml"] + var parsed any + if err := yaml.Unmarshal([]byte(content), &parsed); err != nil { + t.Fatalf("not valid YAML: %v\n%s", err, content) + } + must := []string{ + "version: 2.1", + "workflows:", + "plan-prereq", "plan-deploy", + "apply-prereq", "apply-deploy", + "requires:", // CircleCI graph keyword (NOT needs:) + "wfctl migrations up", "--format json", + "wfctl infra apply --config 'deploy.yaml' --auto-approve", + "curl --fail --max-time 30 'https://myapp.example.com/healthz'", + } + for _, m := range must { + if !strings.Contains(content, m) { + t.Errorf(".circleci/config.yml missing %q\n---\n%s", m, content) + } + } + if strings.Contains(content, "needs:") { + t.Error("CircleCI uses requires:, not GHA needs:") + } + // Positive secret-wiring: each secret name must appear (referenced by an apply + // job's run/env), so a renderer that emits NO secret wiring fails this. + for _, s := range richCIPlan().Secrets { + if !strings.Contains(content, s.Name) { + t.Errorf("expected secret %s referenced in output", s.Name) + } + // CircleCI auto-injects project env vars; no redundant NAME: $NAME re-declare. + if strings.Contains(content, " "+s.Name+": $"+s.Name) { + t.Errorf("redundant secret re-declare for %s", s.Name) + } + } + if !strings.Contains(content, "exit 1") { + t.Error("expected plan-guard exit 1") + } + for _, banned := range []string{"go test ./...", "wfctl deploy --image", "docker build", "wfctl ci run --phase migrate"} { + if strings.Contains(content, banned) { + t.Errorf("must NOT contain legacy %q", banned) + } + } +} + +func TestRenderCircleCI_NilPlan(t *testing.T) { + if _, err := cigen.RenderCircleCI(nil); err == nil { + t.Error("expected error for nil plan") + } +} + +func TestRenderCircleCI_SinglePhase(t *testing.T) { + p := richCIPlan() + p.Phases = []cigen.DeployPhase{{Name: "deploy", ConfigPath: "deploy.yaml"}} + files, err := cigen.RenderCircleCI(p) + if err != nil { + t.Fatalf("single-phase: %v", err) + } + if !strings.Contains(files[".circleci/config.yml"], "apply:") && !strings.Contains(files[".circleci/config.yml"], "apply-deploy") { + t.Error("expected an apply job for single phase") + } +} +``` + +**Step 2: Run → FAIL** (`undefined: cigen.RenderCircleCI`). +Run: `GOWORK=off go test ./cigen/ -run TestRenderCircleCI 2>&1 | tail` + +**Step 3: Implement** (`cigen/render_circleci.go`) — CircleCI 2.1: `jobs:` with +per-phase `plan[-name]` (run `wfctl infra plan`) and `apply[-name]` (plan-guard +when PlanGuard, migrations on last phase, `wfctl infra apply`), optional `smoke` +job; `workflows:` graph ordering plan jobs (filter to non-default branches) and +apply jobs (`requires:` previous apply, filter to default branch), smoke requires +last apply. Reference auto-injected project env vars (no `NAME: $NAME` redeclare, +per GitLab precedent). Reuse `migrationsUpCommand`. (Full code authored in +execution to satisfy the YAML + structural assertions.) + +**Step 4: Run → PASS** +Run: `GOWORK=off go test ./cigen/ -run TestRenderCircleCI -v 2>&1 | tail -20` +Expected: `PASS`. + +**Step 5: Commit** +```bash +git add cigen/render_circleci.go cigen/render_circleci_test.go +git commit -m "feat(cigen): config-derived RenderCircleCI (#804)" +``` +Rollback: revert commit — additive. + +--- + +### Task 3: Wire jenkins/circleci into wfctl ci generate + +**Files:** +- Modify: `cmd/wfctl/ci.go` (render switch ~160; legacy `generateCIFiles` switch ~288; usage text ~52/73/104; two `unsupported platform` error strings ~166/~294) +- Modify: `cmd/wfctl/ci_wizard.go` (`platformOptions` ~51) +- Modify/extend: `cmd/wfctl/ci_test.go` (assert the four-platform switch) + +**Step 1: Write/extend the failing test** — add a wfctl-level test that +`generateCIFiles` (or the render switch helper) returns config-derived output for +`jenkins` and `circleci`: + +```go +func TestGenerateCIFiles_JenkinsCircleCI(t *testing.T) { + // Purpose: verify the platform SWITCH dispatches to the cigen renderers. + // Content quality is gated by the cigen unit tests (Tasks 1+2), not here. + cases := map[string]string{"jenkins": "pipeline {", "circleci": "version: 2.1"} + for plat, marker := range cases { + files, err := generateCIFiles(ciOptions{Platform: plat, InfraConfig: "infra.yaml"}) + if err != nil { + t.Fatalf("%s: %v", plat, err) + } + joined := "" + for _, c := range files { + joined += c + } + if !strings.Contains(joined, marker) { + t.Errorf("%s: expected marker %q in output", plat, marker) + } + } +} +``` + +**Step 2: Run → FAIL** (`unsupported platform "jenkins"`). +Run: `GOWORK=off go test ./cmd/wfctl/ -run TestGenerateCIFiles_JenkinsCircleCI 2>&1 | tail` + +**Step 3: Implement** — add `case "jenkins": files, renderErr = cigen.RenderJenkins(plan)` +and `case "circleci": files, renderErr = cigen.RenderCircleCI(plan)` to the render +switch (~160); add `case "jenkins": return generateJenkins(opts)` / `circleci` to +`generateCIFiles` (~288) with `generateJenkins`/`generateCircleCI` helpers +(mirror `generateGitHubActions`); update BOTH `unsupported platform` error strings +and all usage text (~52/73/104) to `github_actions, gitlab_ci, jenkins, circleci`; +add `"jenkins", "circleci"` to `platformOptions` in `ci_wizard.go`. + +**Step 4: Run → PASS + full cigen/wfctl suite + lint** +Run: `GOWORK=off go test ./cmd/wfctl/ ./cigen/ 2>&1 | tail -5` +Expected: `ok`. +Run: `GOWORK=off golangci-lint run --new-from-rev=origin/main ./cigen/... ./cmd/wfctl/... 2>&1 | tail` +Expected: exit 0. + +**Step 5: Runtime-launch validation (CLI change class)** — build wfctl and run it: +```bash +GOWORK=off go build -o /tmp/wfctl-804 ./cmd/wfctl +cd $(mktemp -d) && cp /Users/jon/workspace/workflow/example/*.yaml . 2>/dev/null; \ + /tmp/wfctl-804 ci generate --platform jenkins --config --output-dir out --write && \ + grep -q "pipeline {" out/Jenkinsfile && echo "JENKINS OK" +/tmp/wfctl-804 ci generate --platform circleci --config --output-dir out --write && \ + grep -q "version: 2.1" out/.circleci/config.yml && echo "CIRCLE OK" +``` +Use a real, rich config: `example/api-server-config.yaml` (or, once Task 8 has +authored it, `scenarios/97-…/config/app.yaml`). Replace `` +with that path. +Expected: `JENKINS OK` + `CIRCLE OK`; capture transcript for the PR. (Keep +`/tmp/wfctl-804` — Tasks 8/9 reuse it as `WFCTL_BIN`.) + +**Step 6: Commit** +```bash +git add cmd/wfctl/ci.go cmd/wfctl/ci_wizard.go cmd/wfctl/ci_test.go +git commit -m "feat(wfctl): ci generate --platform jenkins|circleci via cigen (#804)" +``` +Rollback: revert commit — restores three-platform support, additive switch cases. + +--- + +### Task 4: Docs + workflow version bump → v0.68.0 + +**Files:** +- Modify: `DOCUMENTATION.md` / `docs/WFCTL.md` (note four-platform `ci generate`) +- Modify: the workflow version source (the file `wfctl --version` / release reads — confirm at execution: `sdk.ResolveBuildVersion` ldflag vs a VERSION constant; if version is ldflag-injected at release time, no file bump is needed — the tag drives it). + +**Step 1: Update docs** — add jenkins/circleci to the `wfctl ci generate +--platform` reference in `docs/WFCTL.md` and the cigen section of `DOCUMENTATION.md`. + +**Step 2: Verify version mechanism** — +Run: `cd /Users/jon/workspace/workflow && grep -rn "0.67.0\|var version\|ResolveBuildVersion" cmd/wfctl/*.go sdk/ 2>/dev/null | head` +If the version is ldflag-injected by the release workflow (no in-repo constant), +the tag `v0.68.0` is the only bump needed (done at finish/merge). If an in-repo +constant exists, bump it to `0.68.0`. + +**Step 3: Verify docs render** — `grep -n "jenkins" docs/WFCTL.md DOCUMENTATION.md` +Expected: jenkins/circleci listed under `ci generate` platforms. + +**Step 4: Commit** +```bash +git add DOCUMENTATION.md docs/WFCTL.md +git commit -m "docs: wfctl ci generate four-platform support (#804)" +``` +Rollback: revert commit (docs-only). Version-pin rollback: do not push tag +v0.68.0 if PR1 reverted. + +> After PR1 review + CI green + merge: tag **v0.68.0** on workflow main (the +> prerequisite gate for PR2). Version-skew note: nothing else in workflow lags. +> +> **I4 — release-availability gate (MANDATORY before Task 5):** pushing `v0.68.0` +> triggers the workflow `release.yml` action; the Go module proxy only serves +> `@v0.68.0` after the release run completes. Before Task 5's `go get`, wait via a +> bash poll-loop ([[feedback_ci_wait_use_bash_poll_loop]]): +> ```bash +> # 1) wait for the release workflow run to finish +> until gh run list --repo GoCodeAlone/workflow --workflow=release.yml --branch main \ +> --limit 1 --json status,conclusion -q '.[0].status' | grep -q completed; do sleep 30; done +> # 2) confirm the proxy serves the version (retry — proxy lags the release slightly) +> for i in $(seq 1 20); do +> GOWORK=off GOPROXY=https://proxy.golang.org go list -m github.com/GoCodeAlone/workflow@v0.68.0 \ +> 2>/dev/null && break || sleep 30 +> done +> ``` +> Only proceed to Task 5 once `go list -m …@v0.68.0` succeeds. + +--- + +### Task 5: Plugin — bump workflow dependency to v0.68.0 + +**Files:** +- Modify: `go.mod` (workflow `v0.67.0` → `v0.68.0`), `go.sum` + +**Pre-req:** workflow **v0.68.0 must be tagged** (Task 4 close-out) so the module +resolves. + +**Step 1: Bump + tidy** +```bash +cd /Users/jon/workspace/workflow-plugin-ci-generator +go get github.com/GoCodeAlone/workflow@v0.68.0 +GOWORK=off go mod tidy +``` +**Step 2: Verify it resolves + `cigen.RenderJenkins` is visible** +Run: `GOWORK=off go build ./... 2>&1 | tail` (will still fail until Task 6 rewires +the calls; at minimum the module must download — confirm `go list -m +github.com/GoCodeAlone/workflow` prints `v0.68.0`). +Expected: `…/workflow v0.68.0`. + +**Step 3: Commit** +```bash +git add go.mod go.sum +git commit -m "chore: bump workflow to v0.68.0 for cigen jenkins/circleci (#804)" +``` +Rollback: `go get …workflow@v0.67.0 && go mod tidy`; revert commit. + +--- + +### Task 6: Plugin — route jenkins/circleci through cigen, delete platforms package + +**Files:** +- Modify: `internal/generator.go` (4-way cigen switch; delete `registry` map, `Generator` interface, the legacy `default:` template branch, the `platforms` import) +- Delete: `internal/platforms/` (jenkins.go, circleci.go, github_actions.go, gitlab_ci.go, options.go + all `*_test.go`) + +**Step 1: Rewire `generator.go`** — change the platform switch (~77) so all four +route through cigen: +```go +switch platform { +case PlatformGitHubActions, PlatformGitLabCI, PlatformJenkins, PlatformCircleCI: + // ... existing analyze/from-plan block builds `plan` ... + switch platform { + case PlatformGitHubActions: + files, err = cigen.RenderGitHubActions(plan) + case PlatformGitLabCI: + files, err = cigen.RenderGitLabCI(plan) + case PlatformJenkins: + files, err = cigen.RenderJenkins(plan) + case PlatformCircleCI: + files, err = cigen.RenderCircleCI(plan) + } + // ... err handling ... +} +``` +Delete the `default:` template branch, the `registry` var (~35), the `Generator` +interface (~27), and the `platforms` import. `knownPlatforms` stays (validates the +platform string). + +**Step 2: Delete the platforms package** +```bash +git rm -r internal/platforms/ +``` + +**Step 3: Build → expect test-file compile errors (fixed in Task 7)** +Run: `GOWORK=off go build ./... 2>&1 | tail` +Expected: `internal/` builds; `go vet`/test compile fails only in `*_test.go` +(handled next). Production build clean. + +**Step 4: Commit (LOCAL ONLY — do NOT push yet)** +```bash +git add internal/generator.go && git rm -r internal/platforms/ +git commit -m "feat: route all four platforms through cigen, delete template generators (#804)" +``` +> **C2 — broken-CI window:** between this commit and Task 7 the `*_test.go` files +> reference the deleted `registry`/`Generator`/`platforms.Options` and will NOT +> compile. Do NOT push the PR2 branch to remote until Task 7 makes `go test ./...` +> green — `finishing-a-development-branch` pushes the whole branch once, so CI only +> ever sees the Task-7-complete state. + +Rollback: `git revert` restores `internal/platforms/` and the registry. + +--- + +### Task 7: Plugin — rewrite tests + plugin version bump → v0.2.0 + +**Files:** +- Modify: `internal/generator_test.go` (replace the two `_TemplateUnchanged` tests with `_CigenMarkers`; delete `staticGenerator` + `registerTestGenerator`; rewrite the path-safety + sort tests to call package functions directly) +- Modify: `integration_test.go` (replace the `wftest.MockStep` circleci test with a real `ExecuteCIGenerate` call; add jenkins) +- Modify: `plugin.json` (`0.1.6` → `0.2.0`) + +**Step 0 (I3 — confirm testdata richness):** the new `_CigenMarkers` tests rely on +the same `testdataConfig` the GHA/GitLab marker tests use. Confirm that config +yields migrations + secrets BEFORE authoring the jenkins/circleci equivalents: +Run: `cd /Users/jon/workspace/workflow-plugin-ci-generator && GOWORK=off go test ./internal/ -run 'TestExecuteCIGenerateGitHubActions_CigenMarkers' -v 2>&1 | tail` +Expected: PASS (proves the shared testdata config → a plan with Migrations + +Secrets; the same plan feeds RenderJenkins/RenderCircleCI, so their markers will +render). If it FAILS or the config lacks an `infra.*` module, enrich the testdata +config first. + +**Step 1: Rewrite `internal/generator_test.go`** — +- Replace `TestExecuteCIGenerateJenkins_TemplateUnchanged` (~267) and + `TestExecuteCIGenerateCircleCI_TemplateUnchanged` (~306) with + `TestExecuteCIGenerateJenkins_CigenMarkers` / + `TestExecuteCIGenerateCircleCI_CigenMarkers` that call `ExecuteCIGenerate` for + the platform, read the written file, and assert config-derived markers (secret + env, `wfctl migrations up`, smoke, plan-guard) + **absence** of `go test ./...` + / `wfctl deploy --image`. Mirror the existing GHA/GitLab `_CigenMarkers` tests + (~66/~127). +- Delete `staticGenerator` (~411) + `registerTestGenerator` (~421). +- `TestExecuteCIGenerateRejectsUnsafeGeneratedPath` → a direct unit test of + `validateRelativeOutputPath` (`generator.go:193`, same package) asserting + `../escape` and absolute paths error and `Jenkinsfile` is accepted. +- `TestExecuteCIGenerateSortsFilesWritten` → assert the `FilesWritten` slice is + sorted on a real `ExecuteCIGenerate` render that writes ≥1 file (use a config + yielding a deterministic set; or assert `sort.StringsAreSorted` over the output). + +**Step 2: Rewrite the circleci integration test** (`integration_test.go` ~140) — +replace the `wftest.MockStep` with a real `ExecuteCIGenerate` call writing to +`t.TempDir()`, asserting the `.circleci/config.yml` is config-derived; add the +jenkins equivalent. This is the **acceptance-#2 plugin-path proof**. + +**Step 3: Run the suite → PASS** +Run: `cd /Users/jon/workspace/workflow-plugin-ci-generator && GOWORK=off go test ./... 2>&1 | tail -15` +Expected: `ok` for `internal` + root integration package; no reference to +`internal/platforms`. +Run: `GOWORK=off golangci-lint run --new-from-rev=origin/main ./... 2>&1 | tail` +Expected: exit 0. + +**Step 4: Plugin-load runtime validation (plugin change class)** — build the +plugin binary in the wfctl-discoverable layout + drive a representative call: +```bash +GOWORK=off go build -o /tmp/ci-gen-plugin/ci-generator/ci-generator ./cmd/plugin +cp plugin.json /tmp/ci-gen-plugin/ci-generator/ +# representative: run the plugin's ExecuteCIGenerate via the integration test (already real) +GOWORK=off go test ./... -run Integration 2>&1 | tail +``` +Expected: integration tests pass (real plugin entry point renders config-derived +jenkins/circleci). Capture transcript for the PR. + +**Step 5: Bump plugin version** +Edit `plugin.json` version `0.1.6` → `0.2.0`. + +**Step 6: Commit** +```bash +git add internal/generator_test.go integration_test.go plugin.json +git commit -m "test+chore: cigen-route tests for jenkins/circleci, plugin v0.2.0 (#804)" +``` +Rollback: revert commits; pin plugin back to v0.1.6 + workflow v0.67.0. + +> After PR2 review + CI green + merge: tag the plugin **v0.2.0**. + +--- + +### Task 8: workflow-scenarios — scenario 97 files + registration + +**Files:** +- Create: `scenarios/97-ci-generate-jenkins-circleci/scenario.yaml` +- Create: `scenarios/97-ci-generate-jenkins-circleci/README.md` +- Create: `scenarios/97-ci-generate-jenkins-circleci/config/app.yaml` (real config: a `secrets.entries` block; an `infra.container_service` with `health_check.http_path` + a PRIMARY `domains` entry → smoke; a `ci.migrations` entry with `database.env` → migrations; a `protected: true` module → plan-guard; an `infra.*` module → plugin-install) +- Create: `scenarios/97-ci-generate-jenkins-circleci/config/step-ci-generate.yaml` (a small pipeline with `step.ci_generate` `platform: jenkins` and `platform: circleci` — for the `wfctl validate` config-shape check) +- Create: `scenarios/97-ci-generate-jenkins-circleci/test/run.sh` (executable) +- Modify: `scenarios.json` (register id 97) + +**Step 0 (m1 — schema accepts jenkins/circleci):** the `step.ci_generate` config +shape check only works if the step schema accepts `platform: jenkins|circleci`. +The legacy plugin registry already supported both platforms, so the schema is +expected to accept them, but confirm: +Run: `WFCTL_BIN=/tmp/wfctl-804; $WFCTL_BIN get-step-schema step.ci_generate 2>/dev/null | grep -A6 -i platform || echo "no enum constraint"` +Expected: `platform` has no enum, OR the enum includes jenkins/circleci. If the +schema enumerates only github_actions/gitlab_ci, drop the validate sub-check +(Step 2 item 4) and note it — the behavior proof (PR2 integration test) still +covers acceptance #2. + +**Step 1: Author `config/app.yaml`** — a minimal but real workflow config that +`cigen.Analyze` derives a rich plan from. Required for each derivation (per +`cigen/analyze.go`): smoke ⇐ an `infra.container_service` module with +`health_check.http_path` + a `domains` entry `type: PRIMARY`; migrations ⇐ a +`ci.migrations[]` entry with `database.env`; plan-guard ⇐ any module with +`protected: true`; plugin-install ⇐ any `infra.*`/`iac.*`/`plugin.*` module; +secrets ⇐ a `secrets.entries` block. Validate it parses: `python3 -c "import yaml; +yaml.safe_load(open('.../config/app.yaml'))"`. After authoring, dry-run the +analyzer to confirm the plan is rich: `$WFCTL_BIN ci plan --config +.../config/app.yaml` shows migrations + secrets + smoke + plan_guard. + +**Step 2: Author `test/run.sh`** (mirror scenario 77's wfctl-locate + PASS/FAIL +harness) that: +1. Locates wfctl (`WFCTL_BIN`/`$WORKFLOW_REPO/bin/wfctl`/`which wfctl`); `skip` if absent. +2. `wfctl ci generate --platform jenkins --config config/app.yaml --output-dir $TMP/j --write` then asserts `$TMP/j/Jenkinsfile` contains a config secret name, `wfctl migrations up`, the smoke URL, plan-guard `exit 1`, `wfctl infra apply`, and does NOT contain `go test ./...` / `wfctl deploy --image`. +3. Same for `--platform circleci` → `$TMP/c/.circleci/config.yml` (+ assert `version: 2.1`, `requires:`). +4. `wfctl validate --skip-unknown-types config/step-ci-generate.yaml` passes (config-shape half of acceptance #2). +5. Prints `Results: N passed, M failed` and exits non-zero on any FAIL. + +**Step 3: Author `scenario.yaml` + `README.md`** (category C, status testable, +tags ci/jenkins/circleci/cigen/config-derived). **Register in `scenarios.json`** +with id `97-ci-generate-jenkins-circleci`. + +**Step 4: Lint the harness** — `bash -n scenarios/97-ci-generate-jenkins-circleci/test/run.sh` +Expected: no syntax error. Confirm registration: `python3 -c "import json; +print('97-ci-generate-jenkins-circleci' in open('scenarios.json').read())"` → `True`. + +**Step 5: Commit** +```bash +git add scenarios/97-ci-generate-jenkins-circleci/ scenarios.json +git commit -m "test(scenario-97): config-derived jenkins/circleci CI generation (#804)" +``` +Rollback: revert commit (scenario-only). + +--- + +### Task 9: workflow-scenarios — run the scenario, capture honest evidence + +**Files:** +- Create: `scenarios/97-ci-generate-jenkins-circleci/test/artifacts/last-run.log` (the real run output) + +**Step 1: Build wfctl from the PR1 branch** (or reuse `/tmp/wfctl-804` from Task 3): +```bash +cd /Users/jon/workspace/workflow && git checkout feat/cigen-jenkins-circleci-804 && \ + GOWORK=off go build -o /tmp/wfctl-804 ./cmd/wfctl +``` +**Step 2: Run scenario 97 against the real binary** (demonstration-fidelity — +real `wfctl ci generate`, not a reimplementation): +```bash +cd /Users/jon/workspace/workflow-scenarios && \ + WFCTL_BIN=/tmp/wfctl-804 bash scenarios/97-ci-generate-jenkins-circleci/test/run.sh \ + | tee scenarios/97-ci-generate-jenkins-circleci/test/artifacts/last-run.log +``` +Expected: `Results: N passed, 0 failed` — the gate is **0 failed** (the exact +passed count = however many assertions the authored `run.sh` contains; do not +hard-code a target). If any FAIL → fix the renderer (Task 1/2) or the scenario, +re-run. The log is the honest evidence pasted into the PR3 body. + +**Step 3: Commit the evidence** +```bash +git add scenarios/97-ci-generate-jenkins-circleci/test/artifacts/last-run.log +git commit -m "test(scenario-97): captured real wfctl ci generate evidence (#804)" +``` +Rollback: revert commit. + +--- + +## Verification summary (change-class mapping) + +| Task | Change class | Verification | Expected | +|---|---|---|---| +| 1 | Go code (renderer) | `go test ./cigen/ -run TestRenderJenkins` | PASS (config-derived markers, no legacy stages) | +| 2 | Go code (renderer) | `go test ./cigen/ -run TestRenderCircleCI` | PASS (valid YAML + structure) | +| 3 | CLI command | `go test ./cmd/wfctl/` + build + `wfctl ci generate --platform jenkins/circleci` run | help/usage lists 4 platforms; real run writes Jenkinsfile/.circleci | +| 4 | Docs + version pin | grep docs; confirm version mechanism | jenkins/circleci documented; v0.68.0 tag drives release | +| 5 | Version pin | `go list -m …workflow` | v0.68.0 resolves | +| 6 | Go code (rewire+delete) | `go build ./...` | production builds; platforms package gone | +| 7 | Plugin + tests | `go test ./...` + plugin-load run + golangci-lint | config-derived jenkins/circleci via ExecuteCIGenerate; v0.2.0 | +| 8 | Test scenario | `bash -n run.sh` + registration check | valid harness; registered id 97 | +| 9 | Multi-component proof | run scenario vs real wfctl | `N passed, 0 failed`; evidence committed | + +## Multi-Component / Integration proof + +The real boundaries: (a) cigen↔wfctl — Task 3 builds + runs the real wfctl +(`ci generate --platform jenkins/circleci`); Task 9 runs the scenario against that +binary. (b) cigen↔plugin — Task 7's `integration_test.go` drives the real +`ExecuteCIGenerate` for both platforms and asserts on written files (acceptance +#2). No mock-only validation on either boundary. diff --git a/docs/plans/2026-05-31-cigen-jenkins-circleci.md.scope-lock b/docs/plans/2026-05-31-cigen-jenkins-circleci.md.scope-lock new file mode 100644 index 00000000..78d608d2 --- /dev/null +++ b/docs/plans/2026-05-31-cigen-jenkins-circleci.md.scope-lock @@ -0,0 +1 @@ +cb67a561b4ccb7f9cc3cdaadb04837bcb4f3e1f67dc94cdd261d8d6872a1c62b