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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions cigen/render_circleci.go
Original file line number Diff line number Diff line change
@@ -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")
}
80 changes: 80 additions & 0 deletions cigen/render_circleci_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading