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
10 changes: 9 additions & 1 deletion cigen/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type Options struct {
// so generated CI steps and path filters reference a real checkout path
// rather than a deleted /tmp file.
ConfigPathAlias string
// PhaseConfigAlias is the ConfigPathAlias equivalent for the prereq phase.
// When set, it is used verbatim as the prereq DeployPhase.ConfigPath
// instead of the relativized PhaseConfig filesystem path.
PhaseConfigAlias string
}

// Analyze reads the workflow config files in configs and derives a CIPlan.
Expand Down Expand Up @@ -99,7 +103,11 @@ func Analyze(configs []string, opts Options) (*CIPlan, error) {
}
phaseConfigPath := opts.PhaseConfig
if phaseConfigPath != "" {
phaseConfigPath = relativizeConfigPath(phaseConfigPath)
if opts.PhaseConfigAlias != "" {
phaseConfigPath = opts.PhaseConfigAlias
} else {
phaseConfigPath = relativizeConfigPath(phaseConfigPath)
}
}
plan.Phases = derivePhases(primaryConfigPath, phaseConfigPath)

Expand Down
80 changes: 80 additions & 0 deletions cigen/analyze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,83 @@ func TestAnalyze_ConfigPathAliasUsedVerbatim(t *testing.T) {
"deploy.yaml", plan.Phases[len(plan.Phases)-1].ConfigPath)
}
}

func TestAnalyze_MultisiteGolden(t *testing.T) {
// Golden test: load the REAL gocodealone-multisite configs (copied verbatim
// into testdata/multisite/) and assert Analyze produces the expected shape.
// The exact warning text is asserted from the plan.json warnings[] array
// produced by the real binary run during the Task 18 evidence session.
plan, err := cigen.Analyze(
[]string{"testdata/multisite/deploy.yaml"},
cigen.Options{
PhaseConfig: "testdata/multisite/deploy.prereq.yaml",
},
)
if err != nil {
t.Fatalf("Analyze: %v", err)
}

// Two phases: prereq then deploy
if len(plan.Phases) != 2 {
t.Fatalf("expected 2 phases, got %d", len(plan.Phases))
}
if plan.Phases[0].Name != "prereq" {
t.Errorf("expected phase[0].Name=%q, got %q", "prereq", plan.Phases[0].Name)
}
if plan.Phases[1].Name != "deploy" {
t.Errorf("expected phase[1].Name=%q, got %q", "deploy", plan.Phases[1].Name)
}

// Secrets union: deploy.yaml has 16 named secrets (14 entries + 2 provider keys)
// The real plan.json contains 16 secrets total.
if len(plan.Secrets) < 14 {
t.Errorf("expected ≥14 secrets in union, got %d", len(plan.Secrets))
}

// PluginInstall: iac.provider, iac.state, analytics.google_provider, infra.database → true
if !plan.PluginInstall {
t.Error("expected PluginInstall=true (iac.* + analytics.* modules present)")
}

// PlanGuard: multisite-pg and gocodealone-multisite are both protected: true
if !plan.PlanGuard {
t.Error("expected PlanGuard=true (protected modules present)")
}

// Migrations: ci.migrations[0].database.env = MULTISITE_DB_URL
if plan.Migrations == nil {
t.Fatal("expected Migrations to be non-nil")
}
if plan.Migrations.DBEnv != "MULTISITE_DB_URL" {
t.Errorf("Migrations.DBEnv = %q, want %q", plan.Migrations.DBEnv, "MULTISITE_DB_URL")
}

// Smoke: infra.container_service with domain gocodealone.tech + health_check /healthz
if plan.Smoke == nil {
t.Fatal("expected Smoke to be non-nil")
}
if !strings.HasSuffix(plan.Smoke.URL, "/healthz") {
t.Errorf("Smoke.URL = %q, expected suffix /healthz", plan.Smoke.URL)
}

// Warnings: must include a DB-url warning (state-derived) and a SPACES casing warning
if len(plan.Warnings) == 0 {
t.Fatal("expected non-empty Warnings")
}
dbWarn := false
casewarn := false
for _, w := range plan.Warnings {
if strings.Contains(w, "MULTISITE_DB_URL") && strings.Contains(w, "hash suffix") {
dbWarn = true
}
if strings.Contains(w, "SPACES_access_key") && strings.Contains(w, "upper-case") {
casewarn = true
}
}
if !dbWarn {
t.Errorf("expected warning mentioning MULTISITE_DB_URL hash suffix, got: %v", plan.Warnings)
}
if !casewarn {
t.Errorf("expected warning mentioning SPACES_access_key upper-case, got: %v", plan.Warnings)
}
}
3 changes: 3 additions & 0 deletions cigen/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ type MigrationsSpec struct {
DBEnv string `json:"db_env"`
// Source is the migrations source directory.
Source string `json:"source,omitempty"`
// Env is the deploy environment name passed to `wfctl migrations up --env`.
// Empty when no environment is configured; the --env flag is then omitted.
Env string `json:"env,omitempty"`
}

// SmokeSpec describes the post-deploy smoke test.
Expand Down
22 changes: 18 additions & 4 deletions cigen/render_gha.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,33 @@ func writeApplyJob(b *strings.Builder, jobName string, phase DeployPhase, needs
b.WriteString(" fi\n")
}

// Migrations step (only in the last phase)
// Migrations step (only in the last phase). Use `wfctl migrations up`,
// the real migration runner — `wfctl ci run --phase migrate` is NOT a valid
// phase (ci run only accepts build|test|deploy) and would fail at runtime.
// No step-level `env:` is needed: deriveSecrets always adds the migrations
// DBEnv to the secrets union, so it is already in the apply job's job-level
// `env:` block above; re-declaring it here would be redundant.
isLastPhase := phase.Name == p.Phases[len(p.Phases)-1].Name
if isLastPhase && p.Migrations != nil {
b.WriteString(" - name: Run migrations\n")
fmt.Fprintf(b, " run: wfctl ci run --config '%s' --phase migrate\n", phase.ConfigPath)
b.WriteString(" env:\n")
fmt.Fprintf(b, " %s: ${{ secrets.%s }}\n", p.Migrations.DBEnv, p.Migrations.DBEnv)
fmt.Fprintf(b, " run: %s\n", migrationsUpCommand(phase.ConfigPath, p.Migrations.Env))
}

fmt.Fprintf(b, " - name: Apply %s\n", phase.Name)
fmt.Fprintf(b, " run: wfctl infra apply --config '%s' --auto-approve\n", phase.ConfigPath)
}

// migrationsUpCommand builds the `wfctl migrations up` invocation. The deploy
// env is included as `--env <env>` only when MigrationsSpec.Env is set;
// otherwise it is omitted (the command defaults to the empty environment).
func migrationsUpCommand(configPath, env string) string {
cmd := fmt.Sprintf("wfctl migrations up --config '%s'", configPath)
if env != "" {
cmd += fmt.Sprintf(" --env %s", env)
}
return cmd
}

// writePhasePaths emits the paths filter for push/pull_request triggers.
func writePhasePaths(b *strings.Builder, p *CIPlan) {
b.WriteString(" paths:\n")
Expand Down
53 changes: 51 additions & 2 deletions cigen/render_gha_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,57 @@ func TestRenderGitHubActions_MigrationsStep(t *testing.T) {
break
}

if !strings.Contains(content, "migrate") {
t.Error("expected migrations step in output")
// Must emit the REAL migration runner. `wfctl ci run --phase migrate` is not
// a valid phase (ci run only accepts build|test|deploy) and would fail at
// runtime, so it must NOT appear.
if !strings.Contains(content, "wfctl migrations up --config") {
t.Errorf("expected migrations step to run 'wfctl migrations up --config', got:\n%s", content)
}
if strings.Contains(content, "--phase migrate") {
t.Error("migrations step must NOT use 'wfctl ci run --phase migrate' (not a valid phase)")
}

// The DB secret must still be available to the migrations step via the
// apply job's job-level `env:` block (deriveSecrets always adds DBEnv to
// the union). The migrations step must NOT re-declare it with a redundant
// step-level `env:`.
if !strings.Contains(content, " APP_DB_URL: ${{ secrets.APP_DB_URL }}") {
t.Errorf("expected DBEnv secret in job-level env block, got:\n%s", content)
}
migIdx := strings.Index(content, "- name: Run migrations")
if migIdx < 0 {
t.Fatalf("expected a 'Run migrations' step, got:\n%s", content)
}
// Slice from the migrations step to the next step (Apply) and assert no
// step-level env block appears inside it.
rest := content[migIdx:]
if nextIdx := strings.Index(rest[1:], "- name:"); nextIdx >= 0 {
rest = rest[:nextIdx+1]
}
if strings.Contains(rest, "env:") {
t.Errorf("migrations step must NOT carry a redundant step-level env: block, got:\n%s", rest)
}
}

func TestRenderGitHubActions_MigrationsStep_WithEnv(t *testing.T) {
plan := richCIPlan()
plan.Migrations.Env = "prod"

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, "wfctl migrations up --config") {
t.Errorf("expected 'wfctl migrations up --config' in output, got:\n%s", content)
}
if !strings.Contains(content, "--env prod") {
t.Errorf("expected '--env prod' when Migrations.Env is set, got:\n%s", content)
}
}

Expand Down
2 changes: 1 addition & 1 deletion cigen/render_gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func renderGitLabWorkflow(p *CIPlan) (string, error) {
b.WriteString(" script:\n")
isLastPhase := i == len(p.Phases)-1
if isLastPhase && p.Migrations != nil {
fmt.Fprintf(&b, " - wfctl ci run --config '%s' --phase migrate\n", phase.ConfigPath)
fmt.Fprintf(&b, " - %s\n", migrationsUpCommand(phase.ConfigPath, p.Migrations.Env))
}
fmt.Fprintf(&b, " - wfctl infra apply --config '%s' --auto-approve\n", phase.ConfigPath)
b.WriteString(" environment:\n")
Expand Down
Loading
Loading