Skip to content
Merged
424 changes: 424 additions & 0 deletions cigen/analyze.go

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions cigen/analyze_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package cigen_test

import (
"path/filepath"
"strings"
"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)
}
}

func TestAnalyze_AbsolutePathRelativized(t *testing.T) {
// When given an absolute path under cwd, the resulting phase ConfigPath must
// be relativized (no leading slash) so the generated CI `paths:` filter and
// `--config` args reference a checkout-relative path.
abs, err := filepath.Abs("testdata/app.yaml")
if err != nil {
t.Fatalf("abs: %v", err)
}
if !filepath.IsAbs(abs) {
t.Fatalf("expected an absolute path, got %q", abs)
}

plan, err := cigen.Analyze([]string{abs}, cigen.Options{})
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if len(plan.Phases) != 1 {
t.Fatalf("expected 1 phase, got %d", len(plan.Phases))
}
got := plan.Phases[0].ConfigPath
if filepath.IsAbs(got) {
t.Errorf("expected relativized ConfigPath, got absolute %q", got)
}
if strings.HasPrefix(got, "/") {
t.Errorf("ConfigPath must not start with /, got %q", got)
}
if got != filepath.Join("testdata", "app.yaml") {
t.Errorf("expected relative path %q, got %q", filepath.Join("testdata", "app.yaml"), got)
}
}

func TestAnalyze_ConfigPathAliasUsedVerbatim(t *testing.T) {
// When ConfigPathAlias is set (the MCP path), the primary phase ConfigPath
// must be the alias verbatim, NOT the real (temp/absolute) path.
abs, err := filepath.Abs("testdata/app.yaml")
if err != nil {
t.Fatalf("abs: %v", err)
}
plan, err := cigen.Analyze([]string{abs}, cigen.Options{
ConfigPathAlias: "deploy.yaml",
})
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if plan.Phases[len(plan.Phases)-1].ConfigPath != "deploy.yaml" {
t.Errorf("expected primary phase ConfigPath to be alias %q, got %q",
"deploy.yaml", plan.Phases[len(plan.Phases)-1].ConfigPath)
}
}
84 changes: 84 additions & 0 deletions cigen/plan.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading
Loading