From 907d3ac9b6b4f74ad79d071e29ad855a7f259a0e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 15 Jun 2026 15:03:30 -0400 Subject: [PATCH] feat: add internal/scaffold package for cascade init Signed-off-by: Joshua Temple --- internal/scaffold/scaffold.go | 269 +++++++++++++++++++++++++++++ internal/scaffold/scaffold_test.go | 260 ++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 internal/scaffold/scaffold.go create mode 100644 internal/scaffold/scaffold_test.go diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go new file mode 100644 index 00000000..1edf945c --- /dev/null +++ b/internal/scaffold/scaffold.go @@ -0,0 +1,269 @@ +// Package scaffold renders a starter cascade manifest and the matching +// reusable-workflow stubs for a project, so a new repository can adopt cascade +// with a working, self-consistent configuration on the first try. +// +// The rendered output is verified before it is returned: Scaffold runs +// SelfCheck on its own files, which writes them to a temporary directory and +// confirms the manifest parses, validates, and generates orchestration +// workflows. A scaffold that cannot survive the real generator is never handed +// back to the caller. +package scaffold + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// schemaDirective is the YAML language-server schema comment placed as the very +// first line of every generated manifest. It is a YAML comment, so it is inert +// to the parser while still giving editors schema-aware completion. +const schemaDirective = "# yaml-language-server: $schema=https://stablekernel.github.io/cascade/manifest.schema.json" + +const ( + manifestPath = config.DefaultManifestFile + buildPath = ".github/workflows/build.yaml" + deployPath = ".github/workflows/deploy.yaml" +) + +// scaffoldConfig holds the resolved, optional inputs for a scaffold render. +type scaffoldConfig struct { + cliVersion string +} + +// Option customizes optional scaffold behavior. Required inputs are positional +// on Scaffold; Options form the variadic tail so new capability stays additive. +type Option func(*scaffoldConfig) + +// WithCLIVersion overrides the cascade CLI version pinned in the generated +// manifest. An empty value is ignored so the default version is retained. +func WithCLIVersion(v string) Option { + return func(c *scaffoldConfig) { + if v != "" { + c.cliVersion = v + } + } +} + +// Topologies returns the preset environment-name lists keyed by topology name. +// Env names are applied positionally by Scaffold, so callers may substitute +// their own ordered names for any preset. +func Topologies() map[string][]string { + return map[string][]string{ + "no-env": {}, + "two-env": {"dev", "prod"}, + "three-env": {"dev", "staging", "prod"}, + "four-env": {"dev", "test", "uat", "prod"}, + } +} + +// Scaffold renders a starter manifest plus reusable-workflow stubs for project, +// trunkBranch, and the ordered envs list, returning a map of relative path to +// file content. When envs is empty the result is release-only: the manifest and +// build stub are produced with no deploys block and no deploy stub. The output +// is verified with SelfCheck before it is returned, and any SelfCheck failure +// is surfaced to the caller. +func Scaffold(project, trunkBranch string, envs []string, opts ...Option) (map[string]string, error) { + c := scaffoldConfig{cliVersion: config.DefaultCLIVersion} + for _, o := range opts { + o(&c) + } + + manifest, err := renderManifest(trunkBranch, c.cliVersion, envs) + if err != nil { + return nil, fmt.Errorf("rendering manifest: %w", err) + } + + files := map[string]string{ + manifestPath: manifest, + buildPath: strings.ReplaceAll(buildStub, "", project), + } + if len(envs) > 0 { + files[deployPath] = strings.ReplaceAll(deployStub, "", project) + } + + if err := SelfCheck(files); err != nil { + return nil, fmt.Errorf("scaffold self-check failed: %w", err) + } + return files, nil +} + +// manifestDoc mirrors the ci: -> config: shape of a cascade manifest so the +// scaffold can marshal a minimal, ordered document without dragging in the full +// config type and all of its reserved fields. +type manifestDoc struct { + CI struct { + Config manifestConfig `yaml:"config"` + } `yaml:"ci"` +} + +type manifestConfig struct { + TrunkBranch string `yaml:"trunk_branch"` + CLIVersion string `yaml:"cli_version"` + Environments []string `yaml:"environments,omitempty"` + Builds []manifestJob `yaml:"builds"` + Deploys []manifestJob `yaml:"deploys,omitempty"` + Changelog manifestCLEntry `yaml:"changelog"` +} + +type manifestJob struct { + Name string `yaml:"name"` + Workflow string `yaml:"workflow"` + Triggers []string `yaml:"triggers"` +} + +type manifestCLEntry struct { + Contributors bool `yaml:"contributors"` +} + +// renderManifest marshals the starter manifest and prepends the schema +// directive as the first line. +func renderManifest(trunkBranch, cliVersion string, envs []string) (string, error) { + var doc manifestDoc + doc.CI.Config.TrunkBranch = trunkBranch + doc.CI.Config.CLIVersion = cliVersion + if len(envs) > 0 { + doc.CI.Config.Environments = envs + } + doc.CI.Config.Builds = []manifestJob{ + {Name: "build", Workflow: ".github/workflows/build.yaml", Triggers: []string{}}, + } + if len(envs) > 0 { + doc.CI.Config.Deploys = []manifestJob{ + {Name: "deploy", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{}}, + } + } + doc.CI.Config.Changelog = manifestCLEntry{Contributors: true} + + body, err := yaml.Marshal(&doc) + if err != nil { + return "", fmt.Errorf("marshaling manifest yaml: %w", err) + } + return schemaDirective + "\n" + string(body), nil +} + +// SelfCheck writes files to a temporary directory and confirms the manifest +// parses, validates with zero problems, and drives the real workflow +// generators. The promote generator is exercised whenever the parsed config has +// deploys. All failures are wrapped with descriptive context. +func SelfCheck(files map[string]string) error { + dir, err := os.MkdirTemp("", "cascade-scaffold-*") + if err != nil { + return fmt.Errorf("creating temp dir for self-check: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + for rel, content := range files { + abs := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return fmt.Errorf("creating dir for %s: %w", rel, err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", rel, err) + } + } + + parsed, err := config.ParseManifestFile(filepath.Join(dir, manifestPath), config.DefaultManifestKey) + if err != nil { + return fmt.Errorf("parsing scaffolded manifest: %w", err) + } + if parsed.Config == nil { + return fmt.Errorf("parsing scaffolded manifest: nil config") + } + + if problems := config.Validate(parsed.Config); len(problems) > 0 { + return fmt.Errorf("validating scaffolded manifest: %s", strings.Join(problems, "; ")) + } + + if _, err := generate.NewGenerator(parsed.Config, dir).Generate(); err != nil { + return fmt.Errorf("generating orchestration workflow: %w", err) + } + + if len(parsed.Config.Deploys) > 0 { + if _, err := generate.NewPromoteGenerator(parsed.Config, dir).Generate(); err != nil { + return fmt.Errorf("generating promotion workflow: %w", err) + } + } + + return nil +} + +// buildStub is the reusable build workflow rendered for every scaffold. The +// only templated value is ; GitHub Actions ${{ ... }} expressions are +// kept literal by embedding the YAML as a raw string and substituting just the +// project name. +const buildStub = `name: Build +on: + workflow_call: + inputs: + environment: + type: string + required: true + sha: + type: string + required: true + dry_run: + type: boolean + required: false + default: false + outputs: + artifact_id: + description: Immutable artifact identifier (image digest, checksum) + value: ${{ jobs.build.outputs.artifact_id }} +jobs: + build: + runs-on: ubuntu-latest + outputs: + artifact_id: ${{ steps.placeholder.outputs.artifact_id }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + - id: placeholder + name: Placeholder build + run: | + echo "Building for ${{ inputs.environment }} at ${{ inputs.sha }}" + # TODO: replace with your real build; emit a real artifact id + echo "artifact_id=placeholder-${{ inputs.sha }}" >> "$GITHUB_OUTPUT" +` + +// deployStub is the reusable deploy workflow rendered when at least one +// environment is requested. Like buildStub, only is substituted and all +// ${{ ... }} expressions remain literal. +const deployStub = `name: Deploy +on: + workflow_call: + inputs: + environment: + type: string + required: true + sha: + type: string + required: true + dry_run: + type: boolean + required: false + default: false +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha }} + - name: Placeholder deploy + if: ${{ !inputs.dry_run }} + run: | + echo "Deploying to ${{ inputs.environment }} at ${{ inputs.sha }}" + # TODO: replace with your real deploy + - name: Dry run preview + if: ${{ inputs.dry_run }} + run: echo "Would deploy to ${{ inputs.environment }}" +` diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go new file mode 100644 index 00000000..bfb638db --- /dev/null +++ b/internal/scaffold/scaffold_test.go @@ -0,0 +1,260 @@ +package scaffold + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +const wantSchemaDirective = "# yaml-language-server: $schema=https://stablekernel.github.io/cascade/manifest.schema.json" + +// writeFiles materializes a scaffold output map under dir, creating any parent +// directories, and returns the absolute path to the manifest. +func writeFiles(t *testing.T, dir string, files map[string]string) string { + t.Helper() + for rel, content := range files { + abs := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755)) + require.NoError(t, os.WriteFile(abs, []byte(content), 0o644)) + } + return filepath.Join(dir, config.DefaultManifestFile) +} + +// topologyCases enumerates each preset and its expected ordered env list. +func topologyCases() []struct { + name string + envs []string +} { + return []struct { + name string + envs []string + }{ + {"no-env", []string{}}, + {"two-env", []string{"dev", "prod"}}, + {"three-env", []string{"dev", "staging", "prod"}}, + {"four-env", []string{"dev", "test", "uat", "prod"}}, + } +} + +func TestTopologies_PresetEnvLists(t *testing.T) { + got := Topologies() + require.Equal(t, []string{}, got["no-env"]) + require.Equal(t, []string{"dev", "prod"}, got["two-env"]) + require.Equal(t, []string{"dev", "staging", "prod"}, got["three-env"]) + require.Equal(t, []string{"dev", "test", "uat", "prod"}, got["four-env"]) +} + +func TestScaffold_ExpectedFileSet(t *testing.T) { + for _, tc := range topologyCases() { + t.Run(tc.name, func(t *testing.T) { + files, err := Scaffold("acme", "main", tc.envs) + require.NoError(t, err) + + _, hasManifest := files[config.DefaultManifestFile] + assert.True(t, hasManifest, "manifest always present") + _, hasBuild := files[".github/workflows/build.yaml"] + assert.True(t, hasBuild, "build stub always present") + + _, hasDeploy := files[".github/workflows/deploy.yaml"] + if len(tc.envs) > 0 { + assert.True(t, hasDeploy, "deploy stub present when envs non-empty") + assert.Len(t, files, 3) + } else { + assert.False(t, hasDeploy, "no deploy stub for release-only") + assert.Len(t, files, 2) + } + }) + } +} + +func TestScaffold_ManifestParsesValidatesAndGenerates(t *testing.T) { + for _, tc := range topologyCases() { + t.Run(tc.name, func(t *testing.T) { + files, err := Scaffold("acme", "main", tc.envs) + require.NoError(t, err) + + dir := t.TempDir() + manifestPath := writeFiles(t, dir, files) + + parsed, err := config.ParseManifestFile(manifestPath, config.DefaultManifestKey) + require.NoError(t, err) + require.NotNil(t, parsed.Config) + + problems := config.Validate(parsed.Config) + require.Empty(t, problems, "validation problems: %v", problems) + + // Positional env ordering preserved. A release-only manifest omits + // the environments key, so the parsed slice is nil there. + if len(tc.envs) > 0 { + require.Equal(t, tc.envs, parsed.Config.Environments) + } else { + require.Empty(t, parsed.Config.Environments) + } + + out, err := generate.NewGenerator(parsed.Config, dir).Generate() + require.NoError(t, err) + require.NotEmpty(t, out) + + if len(tc.envs) > 0 { + require.NotEmpty(t, parsed.Config.Deploys) + pout, perr := generate.NewPromoteGenerator(parsed.Config, dir).Generate() + require.NoError(t, perr) + require.NotEmpty(t, pout) + } else { + require.Empty(t, parsed.Config.Deploys) + } + }) + } +} + +func TestScaffold_SchemaDirectiveIsFirstLine(t *testing.T) { + for _, tc := range topologyCases() { + t.Run(tc.name, func(t *testing.T) { + files, err := Scaffold("acme", "main", tc.envs) + require.NoError(t, err) + manifest := files[config.DefaultManifestFile] + first := manifest + if idx := strings.IndexByte(manifest, '\n'); idx >= 0 { + first = manifest[:idx] + } + require.Equal(t, wantSchemaDirective, first, "schema directive must be the first line") + }) + } +} + +func TestScaffold_CLIVersionDefaulted(t *testing.T) { + files, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + assert.Contains(t, files[config.DefaultManifestFile], config.DefaultCLIVersion) +} + +func TestScaffold_WithCLIVersionOverride(t *testing.T) { + files, err := Scaffold("acme", "main", []string{"dev", "prod"}, WithCLIVersion("v9.9.9")) + require.NoError(t, err) + assert.Contains(t, files[config.DefaultManifestFile], "v9.9.9") +} + +func TestScaffold_BuildStubDeclaresCallbackInputsOutputs(t *testing.T) { + files, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + stub := files[".github/workflows/build.yaml"] + + // Project name woven in. + assert.Contains(t, stub, "name: Build acme") + // GHA expressions survive literally (collision-safe). + assert.Contains(t, stub, "${{ inputs.sha }}") + assert.Contains(t, stub, "${{ jobs.build.outputs.artifact_id }}") + + var doc map[string]any + require.NoError(t, yaml.Unmarshal([]byte(stub), &doc)) + on, _ := doc["on"].(map[string]any) + require.NotNil(t, on) + wc, _ := on["workflow_call"].(map[string]any) + require.NotNil(t, wc) + inputs, _ := wc["inputs"].(map[string]any) + require.NotNil(t, inputs) + for _, k := range []string{"environment", "sha", "dry_run"} { + _, ok := inputs[k] + assert.True(t, ok, "build inputs missing %s", k) + } + outputs, _ := wc["outputs"].(map[string]any) + require.NotNil(t, outputs) + _, ok := outputs["artifact_id"] + assert.True(t, ok, "build outputs missing artifact_id") +} + +func TestScaffold_DeployStubDeclaresCallbackInputs(t *testing.T) { + files, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + stub := files[".github/workflows/deploy.yaml"] + + assert.Contains(t, stub, "name: Deploy acme") + assert.Contains(t, stub, "${{ inputs.environment }}") + + var doc map[string]any + require.NoError(t, yaml.Unmarshal([]byte(stub), &doc)) + on, _ := doc["on"].(map[string]any) + require.NotNil(t, on) + wc, _ := on["workflow_call"].(map[string]any) + require.NotNil(t, wc) + inputs, _ := wc["inputs"].(map[string]any) + require.NotNil(t, inputs) + for _, k := range []string{"environment", "sha", "dry_run"} { + _, ok := inputs[k] + assert.True(t, ok, "deploy inputs missing %s", k) + } +} + +func TestScaffold_TwoEnv_ProducesDeployStub(t *testing.T) { + files, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + _, ok := files[".github/workflows/deploy.yaml"] + assert.True(t, ok) +} + +func TestScaffold_NoEnv_OmitsDeployStub(t *testing.T) { + files, err := Scaffold("acme", "main", []string{}) + require.NoError(t, err) + _, ok := files[".github/workflows/deploy.yaml"] + assert.False(t, ok) + assert.NotContains(t, files[config.DefaultManifestFile], "deploys:") +} + +func TestSelfCheck_SucceedsOnProducedFiles(t *testing.T) { + for _, tc := range topologyCases() { + t.Run(tc.name, func(t *testing.T) { + files, err := Scaffold("acme", "main", tc.envs) + require.NoError(t, err) + require.NoError(t, SelfCheck(files)) + }) + } +} + +func TestSelfCheck_FailsOnInvalidEnvironmentName(t *testing.T) { + // Environment names key job IDs, so a name containing a dot is not + // job-ID-safe and fails config.Validate. + files, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + corrupt := strings.ReplaceAll(files[config.DefaultManifestFile], + "- dev", "- dev.bad") + files[config.DefaultManifestFile] = corrupt + + err = SelfCheck(files) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "validat") +} + +func TestSelfCheck_FailsOnInvalidYAML(t *testing.T) { + files, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + files[config.DefaultManifestFile] = wantSchemaDirective + "\nci:\n config:\n trunk_branch: [unterminated\n" + + err = SelfCheck(files) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "parsing") +} + +func TestSelfCheck_FailsOnBuildWithoutWorkflow(t *testing.T) { + // A build entry with no workflow: is rejected by config.Validate. + files := map[string]string{ + config.DefaultManifestFile: wantSchemaDirective + "\n" + `ci: + config: + trunk_branch: main + cli_version: ` + config.DefaultCLIVersion + ` + builds: + - name: build + triggers: [] +`, + } + err := SelfCheck(files) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "validat") +}