From 67f247e93022e6c5be55b3bd5acb6495472a0593 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 15:55:47 -0400 Subject: [PATCH 01/10] feat(cigen): CIPlan model + Analyze typed derivations Introduces cigen package with platform-neutral CIPlan struct and Analyze() function that derives PluginInstall, PlanGuard, Migrations, Build, Secrets, Smoke, Phases, and Triggers from typed WorkflowConfig fields. Includes golden fixture + 4 passing tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- cigen/analyze.go | 376 ++++++++++++++++++++++++++++++++++++ cigen/analyze_test.go | 111 +++++++++++ cigen/plan.go | 84 ++++++++ cigen/testdata/app.yaml | 43 +++++ cigen/testdata/minimal.yaml | 5 + 5 files changed, 619 insertions(+) create mode 100644 cigen/analyze.go create mode 100644 cigen/analyze_test.go create mode 100644 cigen/plan.go create mode 100644 cigen/testdata/app.yaml create mode 100644 cigen/testdata/minimal.yaml diff --git a/cigen/analyze.go b/cigen/analyze.go new file mode 100644 index 00000000..6a886311 --- /dev/null +++ b/cigen/analyze.go @@ -0,0 +1,376 @@ +package cigen + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/config" +) + +// varRefPattern matches ${VAR_NAME} references in config strings. +var varRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +// Options controls the behaviour of Analyze. +type Options struct { + // WfctlVersion is the version string to embed in the plan (e.g. "v0.66.0" or "latest"). + WfctlVersion string + // DefaultBranch overrides the default branch (defaults to "main"). + DefaultBranch string + // Runner overrides the GitHub Actions runner label (defaults to "ubuntu-latest"). + Runner string + // PhaseConfig is an optional second config path that becomes a prereq DeployPhase + // inserted before the main phase. + PhaseConfig string + // Project overrides the project name derived from the config file. + Project string +} + +// Analyze reads the workflow config files in configs and derives a CIPlan. +// configs must be non-empty; the first entry is the primary config. +// opts.PhaseConfig, if set, is loaded as a prerequisite phase. +func Analyze(configs []string, opts Options) (*CIPlan, error) { + if len(configs) == 0 { + return nil, fmt.Errorf("cigen.Analyze: at least one config path is required") + } + + primaryPath := configs[0] + cfg, err := config.LoadFromFile(primaryPath) + if err != nil { + return nil, fmt.Errorf("cigen.Analyze: load %s: %w", primaryPath, err) + } + + plan := &CIPlan{ + WfctlVersion: resolveVersion(opts.WfctlVersion), + DefaultBranch: resolveDefault(opts.DefaultBranch, "main"), + Runner: resolveDefault(opts.Runner, "ubuntu-latest"), + Triggers: TriggerSpec{ + PR: true, + PushMain: true, + Dispatch: true, + }, + Warnings: []string{}, + } + + // Project name + plan.Project = resolveProject(opts.Project, primaryPath) + + // PluginInstall: any plugin/* or infra.* module type, or .wfctl-lock.yaml sibling + plan.PluginInstall = detectPluginInstall(cfg, primaryPath) + + // PlanGuard: any ModuleConfig.Protected == true + plan.PlanGuard = detectPlanGuard(cfg) + + // Migrations + plan.Migrations = deriveMigrations(cfg) + + // Build: Dockerfile sibling + plan.Build = deriveBuild(primaryPath) + + // Secrets + plan.Secrets = deriveSecrets(cfg, plan.Migrations) + + // Smoke + plan.Smoke = deriveSmoke(cfg) + + // Warnings + plan.Warnings = deriveWarnings(cfg, plan.Migrations, plan.Secrets) + + // Phases + plan.Phases = derivePhases(primaryPath, opts.PhaseConfig) + + return plan, nil +} + +// resolveVersion returns v if non-empty, otherwise "latest". +func resolveVersion(v string) string { + if v != "" { + return v + } + return "latest" +} + +// resolveDefault returns val if non-empty, otherwise def. +func resolveDefault(val, def string) string { + if val != "" { + return val + } + return def +} + +// resolveProject derives a project name from opts.Project or the config file path. +func resolveProject(explicit, configPath string) string { + if explicit != "" { + return explicit + } + base := filepath.Base(configPath) + ext := filepath.Ext(base) + name := strings.TrimSuffix(base, ext) + // Use the directory name if the file is "app" or "deploy" or similar generic names + if name == "app" || name == "deploy" || name == "infra" { + dir := filepath.Dir(configPath) + if dir != "." && dir != "" { + return filepath.Base(dir) + } + } + return name +} + +// detectPluginInstall returns true if the config references any plugin or infra module, +// or if a .wfctl-lock.yaml file exists in the config's directory. +func detectPluginInstall(cfg *config.WorkflowConfig, configPath string) bool { + for _, m := range cfg.Modules { + if strings.HasPrefix(m.Type, "infra.") || + strings.HasPrefix(m.Type, "iac.") || + strings.HasPrefix(m.Type, "plugin.") || + strings.HasPrefix(m.Type, "analytics.") { + return true + } + } + // Check for .wfctl-lock.yaml sibling + dir := filepath.Dir(configPath) + lockPath := filepath.Join(dir, ".wfctl-lock.yaml") + if _, err := os.Stat(lockPath); err == nil { + return true + } + // Check if config file has a requires.plugins section + if cfg.Requires != nil && len(cfg.Requires.Plugins) > 0 { + return true + } + return false +} + +// detectPlanGuard returns true if any module has Protected == true. +func detectPlanGuard(cfg *config.WorkflowConfig) bool { + for _, m := range cfg.Modules { + if m.Protected { + return true + } + } + return false +} + +// deriveMigrations extracts migration config from the first ci.migrations entry. +func deriveMigrations(cfg *config.WorkflowConfig) *MigrationsSpec { + if cfg.CI == nil || len(cfg.CI.Migrations) == 0 { + return nil + } + m := cfg.CI.Migrations[0] + spec := &MigrationsSpec{ + DBEnv: m.Database.Env, + Source: m.SourceDir, + } + if spec.DBEnv == "" { + return nil + } + return spec +} + +// deriveBuild checks for a Dockerfile in the config directory. +func deriveBuild(configPath string) *BuildSpec { + dir := filepath.Dir(configPath) + dockerfilePath := filepath.Join(dir, "Dockerfile") + if _, err := os.Stat(dockerfilePath); err == nil { + return &BuildSpec{Docker: true} + } + return nil +} + +// deriveSecrets builds the union of all secret references. +func deriveSecrets(cfg *config.WorkflowConfig, migrations *MigrationsSpec) []SecretRef { + seen := make(map[string]bool) + var ordered []string + + addSecret := func(name string) { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + return + } + seen[name] = true + ordered = append(ordered, name) + } + + // 1. secrets.entries + if cfg.Secrets != nil { + for _, entry := range cfg.Secrets.Entries { + addSecret(entry.Name) + } + } + + // 2. ${VAR} refs from module Config["env_vars_secret"] values + for _, m := range cfg.Modules { + if m.Config == nil { + continue + } + if evs, ok := m.Config["env_vars_secret"]; ok { + extractVarRefs(evs, addSecret) + } + // 3. iac.provider token/spaces keys + if strings.HasPrefix(m.Type, "iac.provider") || m.Type == "iac.provider" { + for _, key := range []string{"token", "spaces_access_key", "spaces_secret_key", "accessKey", "secretKey"} { + if val, ok := m.Config[key]; ok { + if s, ok := val.(string); ok { + extractVarRefsFromString(s, addSecret) + } + } + } + } + } + + // 4. migrations DBEnv + if migrations != nil && migrations.DBEnv != "" { + addSecret(migrations.DBEnv) + } + + sort.Strings(ordered) + refs := make([]SecretRef, 0, len(ordered)) + for _, name := range ordered { + refs = append(refs, SecretRef{Name: name}) + } + return refs +} + +// extractVarRefs navigates an interface{} that may be a map[string]any or +// string and calls add for each ${VAR} reference found. +func extractVarRefs(v any, add func(string)) { + switch val := v.(type) { + case string: + extractVarRefsFromString(val, add) + case map[string]any: + for _, mv := range val { + extractVarRefs(mv, add) + } + case map[any]any: + for _, mv := range val { + extractVarRefs(mv, add) + } + } +} + +// extractVarRefsFromString extracts ${VAR} references from a string. +func extractVarRefsFromString(s string, add func(string)) { + for _, match := range varRefPattern.FindAllStringSubmatch(s, -1) { + if len(match) == 2 { + add(match[1]) + } + } +} + +// deriveSmoke extracts a smoke test spec from an infra.container_service module. +func deriveSmoke(cfg *config.WorkflowConfig) *SmokeSpec { + for _, m := range cfg.Modules { + if m.Type != "infra.container_service" { + continue + } + if m.Config == nil { + continue + } + // Get health_check http_path + path := extractHealthCheckPath(m.Config) + if path == "" { + path = "/healthz" + } + // Get primary domain + domain := extractPrimaryDomain(m.Config) + if domain == "" { + continue + } + return &SmokeSpec{ + URL: "https://" + domain + path, + Path: path, + } + } + return nil +} + +// extractHealthCheckPath extracts the http_path from a module's health_check config. +func extractHealthCheckPath(cfg map[string]any) string { + hc, ok := cfg["health_check"] + if !ok { + return "" + } + switch v := hc.(type) { + case map[string]any: + if path, ok := v["http_path"].(string); ok { + return path + } + case map[any]any: + if path, ok := v["http_path"].(string); ok { + return path + } + } + return "" +} + +// extractPrimaryDomain extracts the primary domain from a module's domains config. +func extractPrimaryDomain(cfg map[string]any) string { + domains, ok := cfg["domains"] + if !ok { + return "" + } + switch v := domains.(type) { + case []any: + for _, d := range v { + switch dm := d.(type) { + case map[string]any: + if dt, ok := dm["type"].(string); ok && strings.EqualFold(dt, "PRIMARY") { + if domain, ok := dm["domain"].(string); ok { + return domain + } + } + case map[any]any: + if dt, ok := dm["type"].(string); ok && strings.EqualFold(dt, "PRIMARY") { + if domain, ok := dm["domain"].(string); ok { + return domain + } + } + } + } + } + return "" +} + +// derivePhases builds the ordered list of deploy phases. +func derivePhases(primaryPath, phaseConfig string) []DeployPhase { + var phases []DeployPhase + if phaseConfig != "" { + phases = append(phases, DeployPhase{ + Name: "prereq", + ConfigPath: phaseConfig, + }) + } + phases = append(phases, DeployPhase{ + Name: "deploy", + ConfigPath: primaryPath, + }) + return phases +} + +// upperCasePattern matches valid GitHub Actions secret name pattern. +var upperCasePattern = regexp.MustCompile(`^[A-Z0-9_]+$`) + +// deriveWarnings produces advisory warnings for the operator. +func deriveWarnings(cfg *config.WorkflowConfig, migrations *MigrationsSpec, secrets []SecretRef) []string { + var warnings []string + + // (a) state-derived warning: migrations DBEnv may be hash-suffixed in the real GitHub secret + if migrations != nil && migrations.DBEnv != "" { + warnings = append(warnings, + fmt.Sprintf("secret %q is populated by IaC output — the real GitHub secret name may differ (e.g. include a resource hash suffix); verify the secret name matches what wfctl infra bootstrap writes", + migrations.DBEnv)) + } + + // (b) case/alias warning: secret names not matching ^[A-Z0-9_]+$ + for _, s := range secrets { + if !upperCasePattern.MatchString(s.Name) { + warnings = append(warnings, + fmt.Sprintf("secret %q does not match ^[A-Z0-9_]+$ — the config casing is preserved as-is; you may need a GitHub-side alias if the platform normalises secret names to upper-case", + s.Name)) + } + } + + return warnings +} diff --git a/cigen/analyze_test.go b/cigen/analyze_test.go new file mode 100644 index 00000000..38e451f0 --- /dev/null +++ b/cigen/analyze_test.go @@ -0,0 +1,111 @@ +package cigen_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/cigen" +) + +func TestAnalyze_GoldenFixture(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/app.yaml"}, cigen.Options{ + WfctlVersion: "v0.66.0", + }) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + + // PluginInstall: infra.container_service + analytics.google_provider + iac.provider → true + if !plan.PluginInstall { + t.Error("expected PluginInstall=true (infra/analytics/iac modules present)") + } + + // PlanGuard: infra.container_service has protected: true + if !plan.PlanGuard { + t.Error("expected PlanGuard=true (protected module present)") + } + + // Migrations: ci.migrations[0].database.env = APP_DB_URL + if plan.Migrations == nil { + t.Fatal("expected Migrations to be non-nil") + } + if plan.Migrations.DBEnv != "APP_DB_URL" { + t.Errorf("Migrations.DBEnv = %q, want %q", plan.Migrations.DBEnv, "APP_DB_URL") + } + if plan.Migrations.Source != "migrations" { + t.Errorf("Migrations.Source = %q, want %q", plan.Migrations.Source, "migrations") + } + + // Single phase (no PhaseConfig option provided) + if len(plan.Phases) != 1 { + t.Errorf("expected 1 phase, got %d", len(plan.Phases)) + } + if plan.Phases[0].Name != "deploy" { + t.Errorf("expected phase name %q, got %q", "deploy", plan.Phases[0].Name) + } + + // Triggers: default PR+PushMain+Dispatch + if !plan.Triggers.PR { + t.Error("expected Triggers.PR=true") + } + if !plan.Triggers.PushMain { + t.Error("expected Triggers.PushMain=true") + } + if !plan.Triggers.Dispatch { + t.Error("expected Triggers.Dispatch=true") + } + + // WfctlVersion + if plan.WfctlVersion != "v0.66.0" { + t.Errorf("WfctlVersion = %q, want %q", plan.WfctlVersion, "v0.66.0") + } + + // DefaultBranch and Runner defaults + if plan.DefaultBranch != "main" { + t.Errorf("DefaultBranch = %q, want %q", plan.DefaultBranch, "main") + } + if plan.Runner != "ubuntu-latest" { + t.Errorf("Runner = %q, want %q", plan.Runner, "ubuntu-latest") + } +} + +func TestAnalyze_PhaseConfig(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/app.yaml"}, cigen.Options{ + PhaseConfig: "testdata/prereq.yaml", + }) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if len(plan.Phases) != 2 { + t.Fatalf("expected 2 phases, got %d", len(plan.Phases)) + } + if plan.Phases[0].Name != "prereq" { + t.Errorf("expected first phase %q, got %q", "prereq", plan.Phases[0].Name) + } + if plan.Phases[0].ConfigPath != "testdata/prereq.yaml" { + t.Errorf("expected prereq config path %q, got %q", "testdata/prereq.yaml", plan.Phases[0].ConfigPath) + } + if plan.Phases[1].Name != "deploy" { + t.Errorf("expected second phase %q, got %q", "deploy", plan.Phases[1].Name) + } +} + +func TestAnalyze_DefaultWfctlVersion(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/app.yaml"}, cigen.Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if plan.WfctlVersion != "latest" { + t.Errorf("expected WfctlVersion=%q, got %q", "latest", plan.WfctlVersion) + } +} + +func TestAnalyze_NoMigrationsNoMigrationsSpec(t *testing.T) { + // A minimal config with no ci.migrations should yield Migrations==nil + plan, err := cigen.Analyze([]string{"testdata/minimal.yaml"}, cigen.Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if plan.Migrations != nil { + t.Errorf("expected Migrations=nil for config with no ci.migrations, got %+v", plan.Migrations) + } +} diff --git a/cigen/plan.go b/cigen/plan.go new file mode 100644 index 00000000..e0785bfa --- /dev/null +++ b/cigen/plan.go @@ -0,0 +1,84 @@ +// Package cigen provides an analyze → CIPlan → render pipeline for generating +// CI/CD configuration files from workflow YAML configs. +package cigen + +// CIPlan is a platform-neutral representation of a CI/CD plan derived from +// one or more workflow config files. +type CIPlan struct { + // Project is the name of the project (derived from config or directory). + Project string `json:"project"` + // WfctlVersion is the wfctl version to pin in generated CI files. + WfctlVersion string `json:"wfctl_version"` + // DefaultBranch is the branch that triggers apply jobs. + DefaultBranch string `json:"default_branch"` + // Runner is the runner label used for GitHub Actions jobs. + Runner string `json:"runner"` + // PluginInstall is true when wfctl plugin install should be run before deploy. + PluginInstall bool `json:"plugin_install"` + // Build describes the build phase, or nil when no build is needed. + Build *BuildSpec `json:"build,omitempty"` + // Secrets is the union of all secret references needed by CI. + Secrets []SecretRef `json:"secrets"` + // Phases is the ordered list of deploy phases. + Phases []DeployPhase `json:"phases"` + // Migrations describes database migration config, or nil when none. + Migrations *MigrationsSpec `json:"migrations,omitempty"` + // Smoke describes the smoke test, or nil when no smoke test can be derived. + Smoke *SmokeSpec `json:"smoke,omitempty"` + // PlanGuard is true when a protected resource requires a plan-before-apply gate. + PlanGuard bool `json:"plan_guard"` + // Triggers describes which GitHub events trigger CI jobs. + Triggers TriggerSpec `json:"triggers"` + // Warnings is a list of non-fatal advisory messages surfaced to the operator. + Warnings []string `json:"warnings"` +} + +// BuildSpec describes the build phase. +type BuildSpec struct { + // Docker is true when a Dockerfile was detected. + Docker bool `json:"docker"` + // Image is the image name to build (if derivable). + Image string `json:"image,omitempty"` +} + +// SecretRef is a reference to a named secret required by CI. +type SecretRef struct { + // Name is the secret name as it appears in the CI platform's secret store. + Name string `json:"name"` +} + +// DeployPhase is a single phase in a potentially multi-phase deploy pipeline. +type DeployPhase struct { + // Name is the human-readable phase name (e.g. "prereq", "deploy"). + Name string `json:"name"` + // ConfigPath is the workflow config file for this phase. + ConfigPath string `json:"config_path"` + // Include is an optional list of module names to include in this phase. + Include []string `json:"include,omitempty"` +} + +// MigrationsSpec describes the database migration step. +type MigrationsSpec struct { + // DBEnv is the environment variable name that holds the database URL. + DBEnv string `json:"db_env"` + // Source is the migrations source directory. + Source string `json:"source,omitempty"` +} + +// SmokeSpec describes the post-deploy smoke test. +type SmokeSpec struct { + // URL is the full URL to curl for a 2xx response. + URL string `json:"url"` + // Path is the HTTP path component (e.g. "/healthz"). + Path string `json:"path"` +} + +// TriggerSpec describes which CI events should trigger each class of job. +type TriggerSpec struct { + // PR triggers plan/lint jobs on pull requests. + PR bool `json:"pr"` + // PushMain triggers apply jobs on push to the default branch. + PushMain bool `json:"push_main"` + // Dispatch allows manual workflow_dispatch triggers. + Dispatch bool `json:"dispatch"` +} diff --git a/cigen/testdata/app.yaml b/cigen/testdata/app.yaml new file mode 100644 index 00000000..25b8b132 --- /dev/null +++ b/cigen/testdata/app.yaml @@ -0,0 +1,43 @@ +modules: + - name: do-provider + type: iac.provider + config: + provider: digitalocean + token: ${DIGITALOCEAN_TOKEN} + spaces_access_key: ${SPACES_access_key} + spaces_secret_key: ${SPACES_secret_key} + + - name: my-app + type: infra.container_service + protected: true + config: + provider: do-provider + health_check: + http_path: /healthz + domains: + - domain: myapp.example.com + type: PRIMARY + zone: example.com + env_vars_secret: + APP_DB_URL: ${APP_DB_URL} + APP_SECRET: ${APP_SECRET} + + - name: my-plugin + type: analytics.google_provider + config: + credentials_json: ${GOOGLE_CREDENTIALS} + +ci: + migrations: + - name: app + driver: golang-migrate + source_dir: migrations + database: + env: APP_DB_URL + +secrets: + entries: + - name: DIGITALOCEAN_TOKEN + description: DigitalOcean API token. + - name: RELEASES_TOKEN + description: GitHub release token. diff --git a/cigen/testdata/minimal.yaml b/cigen/testdata/minimal.yaml new file mode 100644 index 00000000..42661d10 --- /dev/null +++ b/cigen/testdata/minimal.yaml @@ -0,0 +1,5 @@ +modules: + - name: web + type: http.server + config: + port: 8080 From b7b150140631d4d90bf7278221aed61a25fbc99a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 15:56:34 -0400 Subject: [PATCH 02/10] feat(cigen): secrets-union + smoke derivation + Warnings (state-derived/case) Extends Analyze() to union secrets.entries, ${VAR} refs from env_vars_secret and iac.provider config fields, and migrations DBEnv into a deduplicated SecretRef slice. Smoke derives URL+path from an infra.container_service module's health_check + PRIMARY domain. Warnings surface state-derived secrets (IaC output / migrations DBEnv) and non-upper-case secret names. Multisite-shaped fixture + 4 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- cigen/secrets_smoke_test.go | 123 ++++++++++++++++++++++++++++++++++ cigen/testdata/multisite.yaml | 75 +++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 cigen/secrets_smoke_test.go create mode 100644 cigen/testdata/multisite.yaml diff --git a/cigen/secrets_smoke_test.go b/cigen/secrets_smoke_test.go new file mode 100644 index 00000000..27549e73 --- /dev/null +++ b/cigen/secrets_smoke_test.go @@ -0,0 +1,123 @@ +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" +) + +func TestAnalyze_SecretsUnion_Multisite(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/multisite.yaml"}, cigen.Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + + secretNames := make(map[string]bool, len(plan.Secrets)) + for _, s := range plan.Secrets { + secretNames[s.Name] = true + } + + // From secrets.entries + wantFromEntries := []string{ + "DIGITALOCEAN_TOKEN", + "RELEASES_TOKEN", + "GHCR_CREDENTIALS", + "SPACES_access_key", + "SPACES_secret_key", + "MULTISITE_DB_URL", + } + for _, name := range wantFromEntries { + if !secretNames[name] { + t.Errorf("expected secret %q from entries, not found in plan.Secrets", name) + } + } + + // From env_vars_secret ${VAR} refs + wantFromEnvVarsSecret := []string{ + "MULTISITE_DB_URL", // also in entries, deduplicated + "MULTISITE_INGEST_HMAC_SECRET", + "MULTISITE_JWT_SECRET", + } + for _, name := range wantFromEnvVarsSecret { + if !secretNames[name] { + t.Errorf("expected secret %q from env_vars_secret refs, not found in plan.Secrets", name) + } + } + + // From iac.provider token/spaces fields + wantFromIaC := []string{ + "DIGITALOCEAN_TOKEN", + "SPACES_access_key", + "SPACES_secret_key", + } + for _, name := range wantFromIaC { + if !secretNames[name] { + t.Errorf("expected secret %q from iac.provider config, not found in plan.Secrets", name) + } + } + + // From migrations DBEnv + if !secretNames["MULTISITE_DB_URL"] { + t.Error("expected MULTISITE_DB_URL from migrations.DBEnv in plan.Secrets") + } + + // No duplicates + if len(plan.Secrets) != len(secretNames) { + t.Errorf("expected no duplicates: %d unique names but %d entries", len(secretNames), len(plan.Secrets)) + } +} + +func TestAnalyze_Smoke_Multisite(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/multisite.yaml"}, cigen.Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + + if plan.Smoke == nil { + t.Fatal("expected Smoke to be non-nil") + } + if plan.Smoke.URL != "https://gocodealone.tech/healthz" { + t.Errorf("Smoke.URL = %q, want %q", plan.Smoke.URL, "https://gocodealone.tech/healthz") + } + if plan.Smoke.Path != "/healthz" { + t.Errorf("Smoke.Path = %q, want %q", plan.Smoke.Path, "/healthz") + } +} + +func TestAnalyze_Warnings_Multisite(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/multisite.yaml"}, cigen.Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + + // Should have state-derived warning for MULTISITE_DB_URL (IaC output → migrations) + hasStateWarning := false + hasCaseWarning := false + for _, w := range plan.Warnings { + if strings.Contains(w, "MULTISITE_DB_URL") && strings.Contains(w, "IaC output") { + hasStateWarning = true + } + // SPACES_access_key and SPACES_secret_key don't match ^[A-Z0-9_]+$ + if strings.Contains(w, "SPACES_access_key") || strings.Contains(w, "SPACES_secret_key") { + hasCaseWarning = true + } + } + + if !hasStateWarning { + t.Errorf("expected state-derived warning for MULTISITE_DB_URL; warnings: %v", plan.Warnings) + } + if !hasCaseWarning { + t.Errorf("expected case-mismatch warning for SPACES_access_key/SPACES_secret_key; warnings: %v", plan.Warnings) + } +} + +func TestAnalyze_NoSmoke_MinimalConfig(t *testing.T) { + plan, err := cigen.Analyze([]string{"testdata/minimal.yaml"}, cigen.Options{}) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if plan.Smoke != nil { + t.Errorf("expected no Smoke for minimal config, got %+v", plan.Smoke) + } +} diff --git a/cigen/testdata/multisite.yaml b/cigen/testdata/multisite.yaml new file mode 100644 index 00000000..e306e08e --- /dev/null +++ b/cigen/testdata/multisite.yaml @@ -0,0 +1,75 @@ +# multisite-shaped fixture — exercises secrets-union + smoke + warnings + +infra: + auto_bootstrap: true + +ci: + migrations: + - name: multisite + plugin: workflow-plugin-migrations + driver: golang-migrate + source_dir: migrations + database: + env: MULTISITE_DB_URL + validation: + forbid_dirty: true + +secrets: + defaultStore: github-actions + entries: + - name: DIGITALOCEAN_TOKEN + description: DigitalOcean API token. + - name: RELEASES_TOKEN + description: GitHub token. + - name: GHCR_CREDENTIALS + description: GHCR pull cred. + - name: SPACES_access_key + description: DO Spaces access key. + - name: SPACES_secret_key + description: DO Spaces secret key. + - name: MULTISITE_DB_URL + description: Managed Postgres URL. + +modules: + - name: do-provider + type: iac.provider + config: + provider: digitalocean + region: nyc3 + token: ${DIGITALOCEAN_TOKEN} + spaces_access_key: ${SPACES_access_key} + spaces_secret_key: ${SPACES_secret_key} + + - name: iac-state + type: iac.state + config: + backend: spaces + bucket: multisite-iac-state + prefix: prod/ + region: nyc3 + accessKey: ${SPACES_access_key} + secretKey: ${SPACES_secret_key} + + - name: gocodealone-multisite + type: infra.container_service + protected: true + config: + provider: do-provider + region: nyc + image: ghcr.io/gocodealone/gocodealone-multisite:${IMAGE_SHA} + http_port: 8080 + health_check: + http_path: /healthz + routes: + - path: / + domains: + - domain: gocodealone.tech + type: PRIMARY + zone: gocodealone.tech + - domain: www.gocodealone.tech + type: ALIAS + zone: gocodealone.tech + env_vars_secret: + MULTISITE_DB_URL: ${MULTISITE_DB_URL} + MULTISITE_INGEST_HMAC_SECRET: ${MULTISITE_INGEST_HMAC_SECRET} + MULTISITE_JWT_SECRET: ${MULTISITE_JWT_SECRET} From 23d0213c5e277fb9f6dfbc3c65766fb39335efed Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 15:57:44 -0400 Subject: [PATCH 03/10] feat(cigen): GitHub Actions renderer (structured, valid-yaml) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderGitHubActions() emits a single workflow file with plan (PR-only), apply (or apply-prereq→apply-deploy with needs for multi-phase), optional migrations step in the last apply job, smoke curl job, wfctl plugin install step, setup-wfctl version pin, and per-secret ${{ secrets.NAME }} env entries. All output parses as valid YAML. 6 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- cigen/render_gha.go | 223 +++++++++++++++++++++++++++++++++++++++ cigen/render_gha_test.go | 165 +++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 cigen/render_gha.go create mode 100644 cigen/render_gha_test.go diff --git a/cigen/render_gha.go b/cigen/render_gha.go new file mode 100644 index 00000000..152cb270 --- /dev/null +++ b/cigen/render_gha.go @@ -0,0 +1,223 @@ +package cigen + +import ( + "fmt" + "strings" +) + +// RenderGitHubActions generates GitHub Actions workflow YAML files from a CIPlan. +// It returns a map of relative paths to YAML content. +func RenderGitHubActions(p *CIPlan) (map[string]string, error) { + if p == nil { + return nil, fmt.Errorf("RenderGitHubActions: plan is nil") + } + + name := p.Project + if name == "" { + name = "deploy" + } + + content, err := renderGHAWorkflow(p, name) + if err != nil { + return nil, err + } + + filename := fmt.Sprintf(".github/workflows/%s.yml", sanitizeFilename(name)) + return map[string]string{ + filename: content, + }, nil +} + +// sanitizeFilename replaces characters invalid in filenames with dashes. +func sanitizeFilename(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return b.String() +} + +// renderGHAWorkflow produces the full workflow YAML content. +func renderGHAWorkflow(p *CIPlan, name string) (string, error) { + branch := p.DefaultBranch + if branch == "" { + branch = "main" + } + runner := p.Runner + if runner == "" { + runner = "ubuntu-latest" + } + version := p.WfctlVersion + if version == "" { + version = "latest" + } + + var b strings.Builder + + // Header + triggers + b.WriteString(fmt.Sprintf("name: %s\n", name)) + b.WriteString("on:\n") + if p.Triggers.PR { + b.WriteString(" pull_request:\n") + writePhasePaths(&b, p) + } + if p.Triggers.PushMain { + b.WriteString(" push:\n") + b.WriteString(fmt.Sprintf(" branches: [%s]\n", branch)) + writePhasePaths(&b, p) + } + if p.Triggers.Dispatch { + b.WriteString(" workflow_dispatch:\n") + } + + b.WriteString("permissions:\n") + b.WriteString(" contents: read\n") + b.WriteString(" pull-requests: write\n") + b.WriteString("jobs:\n") + + // Plan job (PR only) + b.WriteString(" plan:\n") + b.WriteString(" if: github.event_name == 'pull_request'\n") + b.WriteString(fmt.Sprintf(" runs-on: %s\n", runner)) + b.WriteString(" steps:\n") + writeCheckoutStep(&b) + writeSetupWfctlStep(&b, version) + if p.PluginInstall { + writePluginInstallStep(&b, p) + } + for _, phase := range p.Phases { + b.WriteString(fmt.Sprintf(" - name: Plan %s\n", phase.Name)) + b.WriteString(fmt.Sprintf(" run: wfctl infra plan --config '%s' --format markdown >> plan.md\n", phase.ConfigPath)) + } + b.WriteString(" - name: Post plan comment\n") + b.WriteString(" uses: actions/github-script@v7\n") + b.WriteString(" with:\n") + b.WriteString(" script: |\n") + b.WriteString(" const fs = require('fs');\n") + b.WriteString(" const plan = fs.readFileSync('plan.md', 'utf8');\n") + b.WriteString(" github.rest.issues.createComment({\n") + b.WriteString(" issue_number: context.issue.number,\n") + b.WriteString(" owner: context.repo.owner,\n") + b.WriteString(" repo: context.repo.repo,\n") + b.WriteString(" body: plan\n") + b.WriteString(" });\n") + + // Apply jobs + if len(p.Phases) == 1 { + writeApplyJob(&b, "apply", p.Phases[0], nil, p, runner, version, branch) + } else { + // Multi-phase: apply-prereq then apply-deploy with needs + prevJob := "" + for i, phase := range p.Phases { + jobName := fmt.Sprintf("apply-%s", phase.Name) + var needs *string + if i > 0 && prevJob != "" { + needs = &prevJob + } + writeApplyJob(&b, jobName, phase, needs, p, runner, version, branch) + prevJob = jobName + } + } + + // Smoke job + if p.Smoke != nil { + lastApplyJob := "apply" + if len(p.Phases) > 1 { + lastPhase := p.Phases[len(p.Phases)-1] + lastApplyJob = fmt.Sprintf("apply-%s", lastPhase.Name) + } + b.WriteString(" smoke:\n") + b.WriteString(fmt.Sprintf(" needs: %s\n", lastApplyJob)) + b.WriteString(fmt.Sprintf(" if: github.event_name == 'push' && github.ref == 'refs/heads/%s'\n", branch)) + b.WriteString(fmt.Sprintf(" runs-on: %s\n", runner)) + b.WriteString(" steps:\n") + b.WriteString(fmt.Sprintf(" - name: Smoke test\n")) + b.WriteString(fmt.Sprintf(" run: |\n")) + b.WriteString(fmt.Sprintf(" curl --fail --max-time 30 '%s'\n", p.Smoke.URL)) + } + + return b.String(), nil +} + +// writeCheckoutStep emits the checkout step. +func writeCheckoutStep(b *strings.Builder) { + b.WriteString(" - uses: actions/checkout@v4\n") +} + +// writeSetupWfctlStep emits the setup-wfctl action step. +func writeSetupWfctlStep(b *strings.Builder, version string) { + b.WriteString(" - name: Install wfctl\n") + b.WriteString(" uses: GoCodeAlone/setup-wfctl@v1\n") + b.WriteString(" with:\n") + b.WriteString(fmt.Sprintf(" version: '%s'\n", version)) +} + +// writePluginInstallStep emits a wfctl plugin install step. +func writePluginInstallStep(b *strings.Builder, p *CIPlan) { + for _, phase := range p.Phases { + b.WriteString(fmt.Sprintf(" - name: Install plugins (%s)\n", phase.Name)) + b.WriteString(fmt.Sprintf(" run: wfctl plugin install --config '%s'\n", phase.ConfigPath)) + } +} + +// writeApplyJob emits a single apply job. +func writeApplyJob(b *strings.Builder, jobName string, phase DeployPhase, needs *string, p *CIPlan, runner, version, branch string) { + b.WriteString(fmt.Sprintf(" %s:\n", jobName)) + b.WriteString(fmt.Sprintf(" if: \"github.event_name == 'push' && github.ref == 'refs/heads/%s' || github.event_name == 'workflow_dispatch'\"\n", branch)) + if needs != nil { + b.WriteString(fmt.Sprintf(" needs: %s\n", *needs)) + } + b.WriteString(fmt.Sprintf(" runs-on: %s\n", runner)) + + // Secrets env block + if len(p.Secrets) > 0 { + b.WriteString(" env:\n") + for _, s := range p.Secrets { + // Use ${{ secrets.NAME }} — use raw string to avoid template interpretation + b.WriteString(fmt.Sprintf(" %s: ${{ secrets.%s }}\n", s.Name, s.Name)) + } + } + + b.WriteString(" steps:\n") + writeCheckoutStep(b) + writeSetupWfctlStep(b, version) + if p.PluginInstall { + b.WriteString(fmt.Sprintf(" - name: Install plugins\n")) + b.WriteString(fmt.Sprintf(" run: wfctl plugin install --config '%s'\n", phase.ConfigPath)) + } + + // PlanGuard: grep for no-op before applying + if p.PlanGuard { + b.WriteString(" - name: Plan guard\n") + b.WriteString(fmt.Sprintf(" run: wfctl infra plan --config '%s' --format json | grep -q '\"changes\":0' || true\n", phase.ConfigPath)) + } + + // Migrations step (only in the last phase) + isLastPhase := phase.Name == p.Phases[len(p.Phases)-1].Name + if isLastPhase && p.Migrations != nil { + b.WriteString(" - name: Run migrations\n") + b.WriteString(fmt.Sprintf(" run: wfctl ci run --config '%s' --phase migrate\n", phase.ConfigPath)) + b.WriteString(" env:\n") + b.WriteString(fmt.Sprintf(" %s: ${{ secrets.%s }}\n", p.Migrations.DBEnv, p.Migrations.DBEnv)) + } + + b.WriteString(fmt.Sprintf(" - name: Apply %s\n", phase.Name)) + b.WriteString(fmt.Sprintf(" run: wfctl infra apply --config '%s' --auto-approve\n", phase.ConfigPath)) +} + +// writePhasePaths emits the paths filter for push/pull_request triggers. +func writePhasePaths(b *strings.Builder, p *CIPlan) { + b.WriteString(" paths:\n") + seen := make(map[string]bool) + for _, phase := range p.Phases { + if !seen[phase.ConfigPath] { + b.WriteString(fmt.Sprintf(" - '%s'\n", phase.ConfigPath)) + seen[phase.ConfigPath] = true + } + } +} diff --git a/cigen/render_gha_test.go b/cigen/render_gha_test.go new file mode 100644 index 00000000..60f1d72f --- /dev/null +++ b/cigen/render_gha_test.go @@ -0,0 +1,165 @@ +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" + "gopkg.in/yaml.v3" +) + +func TestRenderGitHubActions_ValidYAML(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitHubActions(plan) + if err != nil { + t.Fatalf("RenderGitHubActions: %v", err) + } + + if len(files) == 0 { + t.Fatal("expected at least one file in output") + } + + for path, content := range files { + if !strings.HasSuffix(path, ".yml") { + t.Errorf("expected .yml extension, got %q", path) + } + var parsed any + if err := yaml.Unmarshal([]byte(content), &parsed); err != nil { + t.Errorf("file %q is not valid YAML: %v\ncontent:\n%s", path, err, content) + } + } +} + +func TestRenderGitHubActions_TwoPhases(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitHubActions(plan) + if err != nil { + t.Fatalf("RenderGitHubActions: %v", err) + } + + var content string + for _, c := range files { + content = c + break + } + + // Plan job + if !strings.Contains(content, "if: github.event_name == 'pull_request'") { + t.Error("expected plan job with pull_request condition") + } + + // Two-phase apply jobs + if !strings.Contains(content, "apply-prereq:") { + t.Error("expected apply-prereq job for two-phase plan") + } + if !strings.Contains(content, "apply-deploy:") { + t.Error("expected apply-deploy job for two-phase plan") + } + // apply-deploy needs apply-prereq + if !strings.Contains(content, "needs: apply-prereq") { + t.Error("expected apply-deploy to declare needs: apply-prereq") + } +} + +func TestRenderGitHubActions_MigrationsStep(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitHubActions(plan) + if err != nil { + t.Fatalf("RenderGitHubActions: %v", err) + } + var content string + for _, c := range files { + content = c + break + } + + if !strings.Contains(content, "migrate") { + t.Error("expected migrations step in output") + } +} + +func TestRenderGitHubActions_SecretsEnv(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitHubActions(plan) + if err != nil { + t.Fatalf("RenderGitHubActions: %v", err) + } + var content string + for _, c := range files { + content = c + break + } + + for _, s := range plan.Secrets { + marker := "secrets." + s.Name + if !strings.Contains(content, marker) { + t.Errorf("expected ${{ secrets.%s }} in output", s.Name) + } + } +} + +func TestRenderGitHubActions_SmokeJob(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitHubActions(plan) + if err != nil { + t.Fatalf("RenderGitHubActions: %v", err) + } + var content string + for _, c := range files { + content = c + break + } + + if !strings.Contains(content, "smoke:") { + t.Error("expected smoke job in output") + } + if !strings.Contains(content, plan.Smoke.URL) { + t.Errorf("expected smoke URL %q in output", plan.Smoke.URL) + } + if !strings.Contains(content, "curl") { + t.Error("expected curl command in smoke job") + } +} + +func TestRenderGitHubActions_NilPlan(t *testing.T) { + _, err := cigen.RenderGitHubActions(nil) + if err == nil { + t.Error("expected error for nil plan") + } +} + +// richCIPlan returns a CIPlan with 2 phases, migrations, smoke, and 3 secrets. +func richCIPlan() *cigen.CIPlan { + return &cigen.CIPlan{ + Project: "myapp", + WfctlVersion: "v0.66.0", + DefaultBranch: "main", + Runner: "ubuntu-latest", + PluginInstall: true, + PlanGuard: true, + Phases: []cigen.DeployPhase{ + {Name: "prereq", ConfigPath: "deploy.prereq.yaml"}, + {Name: "deploy", ConfigPath: "deploy.yaml"}, + }, + Migrations: &cigen.MigrationsSpec{ + DBEnv: "APP_DB_URL", + Source: "migrations", + }, + Smoke: &cigen.SmokeSpec{ + URL: "https://myapp.example.com/healthz", + Path: "/healthz", + }, + Secrets: []cigen.SecretRef{ + {Name: "SECRET_ONE"}, + {Name: "SECRET_TWO"}, + {Name: "APP_DB_URL"}, + }, + Triggers: cigen.TriggerSpec{PR: true, PushMain: true, Dispatch: true}, + Warnings: []string{}, + } +} From 9d847aa4589d085854c0a59ba8f88dd53817d399 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 15:58:27 -0400 Subject: [PATCH 04/10] feat(cigen): GitLab CI renderer RenderGitLabCI() emits .gitlab-ci.yml with plan/apply/smoke stages, merge-request rules, needs-chaining for multi-phase, secret refs as $NAME CI variables, and plugin install in before_script. No deprecated 'only:' syntax. Output parses as valid YAML. 5 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- cigen/render_gitlab.go | 140 ++++++++++++++++++++++++++++++++++++ cigen/render_gitlab_test.go | 110 ++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 cigen/render_gitlab.go create mode 100644 cigen/render_gitlab_test.go diff --git a/cigen/render_gitlab.go b/cigen/render_gitlab.go new file mode 100644 index 00000000..fe53d1b5 --- /dev/null +++ b/cigen/render_gitlab.go @@ -0,0 +1,140 @@ +package cigen + +import ( + "fmt" + "strings" +) + +// RenderGitLabCI generates a GitLab CI YAML configuration from a CIPlan. +// It returns a map with a single key ".gitlab-ci.yml". +func RenderGitLabCI(p *CIPlan) (map[string]string, error) { + if p == nil { + return nil, fmt.Errorf("RenderGitLabCI: plan is nil") + } + + content, err := renderGitLabWorkflow(p) + if err != nil { + return nil, err + } + + return map[string]string{ + ".gitlab-ci.yml": content, + }, nil +} + +// renderGitLabWorkflow produces the full .gitlab-ci.yml content. +func renderGitLabWorkflow(p *CIPlan) (string, error) { + branch := p.DefaultBranch + if branch == "" { + branch = "main" + } + version := p.WfctlVersion + if version == "" { + version = "latest" + } + + var b strings.Builder + + // Stages + stages := []string{"plan", "apply"} + if p.Smoke != nil { + stages = append(stages, "smoke") + } + b.WriteString("stages:\n") + for _, s := range stages { + b.WriteString(fmt.Sprintf(" - %s\n", s)) + } + b.WriteString("\n") + + // Global variables + b.WriteString("variables:\n") + b.WriteString(fmt.Sprintf(" WFCTL_VERSION: \"%s\"\n", version)) + for _, s := range p.Secrets { + // Secret refs in GitLab CI are just $NAME CI variables + b.WriteString(fmt.Sprintf(" %s: $%s\n", s.Name, s.Name)) + } + b.WriteString("\n") + + // before_script + b.WriteString("before_script:\n") + b.WriteString(" - go install \"github.com/GoCodeAlone/workflow/cmd/wfctl@${WFCTL_VERSION}\"\n") + b.WriteString(" - export PATH=\"$(go env GOPATH)/bin:$PATH\"\n") + if p.PluginInstall { + for _, phase := range p.Phases { + b.WriteString(fmt.Sprintf(" - wfctl plugin install --config '%s'\n", phase.ConfigPath)) + } + } + b.WriteString("\n") + + // Plan jobs + for _, phase := range p.Phases { + jobName := "plan" + if len(p.Phases) > 1 { + jobName = fmt.Sprintf("plan-%s", phase.Name) + } + b.WriteString(fmt.Sprintf("%s:\n", jobName)) + b.WriteString(" stage: plan\n") + b.WriteString(" script:\n") + b.WriteString(fmt.Sprintf(" - wfctl infra plan --config '%s' --format markdown > plan.md\n", phase.ConfigPath)) + b.WriteString(" artifacts:\n") + b.WriteString(" paths:\n") + b.WriteString(" - plan.md\n") + b.WriteString(" expire_in: 1 hour\n") + b.WriteString(" rules:\n") + b.WriteString(" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"\n") + b.WriteString(" changes:\n") + b.WriteString(fmt.Sprintf(" - \"%s\"\n", phase.ConfigPath)) + b.WriteString("\n") + } + + // Apply jobs + prevJob := "" + for i, phase := range p.Phases { + jobName := "apply" + if len(p.Phases) > 1 { + jobName = fmt.Sprintf("apply-%s", phase.Name) + } + b.WriteString(fmt.Sprintf("%s:\n", jobName)) + b.WriteString(" stage: apply\n") + if prevJob != "" { + b.WriteString(" needs:\n") + b.WriteString(fmt.Sprintf(" - job: %s\n", prevJob)) + b.WriteString(" artifacts: false\n") + } + b.WriteString(" script:\n") + isLastPhase := i == len(p.Phases)-1 + if isLastPhase && p.Migrations != nil { + b.WriteString(fmt.Sprintf(" - wfctl ci run --config '%s' --phase migrate\n", phase.ConfigPath)) + } + b.WriteString(fmt.Sprintf(" - wfctl infra apply --config '%s' --auto-approve\n", phase.ConfigPath)) + b.WriteString(" environment:\n") + b.WriteString(" name: production\n") + b.WriteString(" rules:\n") + b.WriteString(fmt.Sprintf(" - if: $CI_COMMIT_BRANCH == \"%s\" && $CI_PIPELINE_SOURCE == \"push\"\n", branch)) + if p.Triggers.Dispatch { + b.WriteString(" - if: $CI_PIPELINE_SOURCE == \"web\"\n") + } + b.WriteString("\n") + prevJob = jobName + } + + // Smoke job + if p.Smoke != nil { + lastApplyJob := "apply" + if len(p.Phases) > 1 { + lastPhase := p.Phases[len(p.Phases)-1] + lastApplyJob = fmt.Sprintf("apply-%s", lastPhase.Name) + } + b.WriteString("smoke:\n") + b.WriteString(" stage: smoke\n") + b.WriteString(" needs:\n") + b.WriteString(fmt.Sprintf(" - job: %s\n", lastApplyJob)) + b.WriteString(" script:\n") + b.WriteString(fmt.Sprintf(" - curl --fail --max-time 30 '%s'\n", p.Smoke.URL)) + b.WriteString(" rules:\n") + b.WriteString(fmt.Sprintf(" - if: $CI_COMMIT_BRANCH == \"%s\" && $CI_PIPELINE_SOURCE == \"push\"\n", branch)) + b.WriteString("\n") + } + + return b.String(), nil +} diff --git a/cigen/render_gitlab_test.go b/cigen/render_gitlab_test.go new file mode 100644 index 00000000..ef9dd4b2 --- /dev/null +++ b/cigen/render_gitlab_test.go @@ -0,0 +1,110 @@ +package cigen_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/cigen" + "gopkg.in/yaml.v3" +) + +func TestRenderGitLabCI_ValidYAML(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitLabCI(plan) + if err != nil { + t.Fatalf("RenderGitLabCI: %v", err) + } + + content, ok := files[".gitlab-ci.yml"] + if !ok { + t.Fatal("expected .gitlab-ci.yml in output") + } + + var parsed any + if err := yaml.Unmarshal([]byte(content), &parsed); err != nil { + t.Errorf(".gitlab-ci.yml is not valid YAML: %v\ncontent:\n%s", err, content) + } +} + +func TestRenderGitLabCI_Stages(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitLabCI(plan) + if err != nil { + t.Fatalf("RenderGitLabCI: %v", err) + } + content := files[".gitlab-ci.yml"] + + for _, stage := range []string{"plan", "apply", "smoke"} { + if !strings.Contains(content, "- "+stage) { + t.Errorf("expected stage %q in output", stage) + } + } +} + +func TestRenderGitLabCI_SecretRefs(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitLabCI(plan) + if err != nil { + t.Fatalf("RenderGitLabCI: %v", err) + } + content := files[".gitlab-ci.yml"] + + for _, s := range plan.Secrets { + if !strings.Contains(content, "$"+s.Name) { + t.Errorf("expected $%s in output", s.Name) + } + } +} + +func TestRenderGitLabCI_TwoPhaseNeeds(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitLabCI(plan) + if err != nil { + t.Fatalf("RenderGitLabCI: %v", err) + } + content := files[".gitlab-ci.yml"] + + // Two-phase plan + if !strings.Contains(content, "plan-prereq:") { + t.Error("expected plan-prereq job") + } + if !strings.Contains(content, "plan-deploy:") { + t.Error("expected plan-deploy job") + } + + // Two-phase apply with needs + if !strings.Contains(content, "apply-prereq:") { + t.Error("expected apply-prereq job") + } + if !strings.Contains(content, "apply-deploy:") { + t.Error("expected apply-deploy job") + } + if !strings.Contains(content, "job: apply-prereq") { + t.Error("expected apply-deploy to need apply-prereq") + } +} + +func TestRenderGitLabCI_NilPlan(t *testing.T) { + _, err := cigen.RenderGitLabCI(nil) + if err == nil { + t.Error("expected error for nil plan") + } +} + +func TestRenderGitLabCI_NoDeprecatedOnlySyntax(t *testing.T) { + plan := richCIPlan() + + files, err := cigen.RenderGitLabCI(plan) + if err != nil { + t.Fatalf("RenderGitLabCI: %v", err) + } + content := files[".gitlab-ci.yml"] + + if strings.Contains(content, "\nonly:") { + t.Error(".gitlab-ci.yml uses deprecated 'only:' syntax") + } +} From 906a405fadeafcd43415e6afb46d833da75b1a90 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 30 May 2026 16:12:21 -0400 Subject: [PATCH 05/10] refactor(wfctl): replace CI templates with cigen-backed generate Replaces the ghaInfraTemplate/ghaBuildTemplate/gitlabCITemplate text templates and their generateGitHubActions/generateGitLabCI functions with thin wrappers that build a minimal CIPlan from ciOptions and delegate to cigen.RenderGitHubActions / cigen.RenderGitLabCI. runCIGenerate now routes through cigen.Analyze + renderer with full --write guard, --diff/--exit-code, --from-plan, and --phase-config flags. Legacy ciOptions + generateCIFiles retained for backward compat. Updated ci_test.go assertions to match cigen output. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/wfctl/ci.go | 410 +++++++++++++++++++------------------------ cmd/wfctl/ci_test.go | 255 +++++++++++++++++++++++---- 2 files changed, 400 insertions(+), 265 deletions(-) diff --git a/cmd/wfctl/ci.go b/cmd/wfctl/ci.go index caea9e64..0190d2dd 100644 --- a/cmd/wfctl/ci.go +++ b/cmd/wfctl/ci.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "flag" "fmt" "os" @@ -10,6 +11,7 @@ import ( "strings" "text/template" + "github.com/GoCodeAlone/workflow/cigen" "gopkg.in/yaml.v3" ) @@ -20,6 +22,8 @@ func runCI(args []string) error { switch args[0] { case "generate": return runCIGenerate(args[1:]) + case "plan": + return runCIPlan(args[1:]) case "run": return runCIRun(args[1:]) case "init": @@ -38,18 +42,27 @@ Generate CI/CD pipeline configuration files. Actions: generate Generate CI config for a supported platform + plan Analyze config and emit a CIPlan JSON (platform-neutral) run Run CI phases (build, test, deploy) from workflow config init Generate bootstrap CI YAML for GitHub Actions or GitLab CI + validate Validate CI config sections Options: - --platform CI platform: github_actions, gitlab_ci (required) - --config Workflow config file (default: app.yaml or infra.yaml) - --output Output directory (default: .) - --runner