diff --git a/README.md b/README.md index 755d48d..217e454 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ go install github.com/stablekernel/cascade/cmd/cascade@latest go install github.com/stablekernel/cascade/cmd/cascade@v0.1.0 ``` +> **Fastest path:** run `cascade init` to scaffold a working starter (manifest with a pinned `cli_version`, build and deploy callback stubs, a `CODEOWNERS`, and an AWS OIDC trust-policy example), then edit two values and push. Pick a shape with `--topology` (`no-env` for a CLI/library, or `two-env`/`three-env`/`four-env` for a promotion pipeline). The scaffold is a verifying starter: `cascade init` then `cascade generate-workflow` leaves `cascade verify` clean. The steps below build the same manifest by hand if you prefer. + ### 2. Create the manifest ```yaml diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 2ee8a17..1fd07f7 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -175,7 +175,7 @@ cascade init --envs staging,production --name my-service cascade init --topology three-env --dry-run ``` -`init` renders `.github/manifest.yaml` plus build (and, when environments are set, deploy) stubs under `.github/workflows`, runs the manifest through parse, validation, and generation, and only then writes the files. The manifest carries a `$schema` directive for editor autocomplete and validation. After running it, fill in the stub callbacks, commit, and run `cascade generate-workflow`. +`init` renders `.github/manifest.yaml` plus build (and, when environments are set, deploy) stubs under `.github/workflows`, runs the manifest through parse, validation, and generation, and only then writes the files. It also drops a starter `.github/CODEOWNERS` and an `.github/aws-oidc-role.example.json` IAM trust-policy example for GitHub Actions OIDC; both carry placeholder owners and account IDs to replace. The manifest carries a `$schema` directive for editor autocomplete and validation. After running it, fill in the stub callbacks, commit, and run `cascade generate-workflow`. The scaffold is a verifying starter: `cascade init` then `cascade generate-workflow` leaves `cascade verify` clean, so you can wire a drift gate from the first commit. #### Flags diff --git a/internal/initcmd/verify_clean_test.go b/internal/initcmd/verify_clean_test.go new file mode 100644 index 0000000..e706507 --- /dev/null +++ b/internal/initcmd/verify_clean_test.go @@ -0,0 +1,65 @@ +package initcmd + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" + "github.com/stablekernel/cascade/internal/verify" +) + +// verifyShape runs the full init -> generate -> verify pipeline for the given +// topology and asserts verify returns nil (no drift, no orphans). +func verifyShape(t *testing.T, topology string) { + t.Helper() + + dir := t.TempDir() + + // Step 1: run the real init command into dir. + _, err := runInit(t, "--topology", topology, "--name", "demo", "--dir", dir) + require.NoError(t, err, "init must succeed for topology %s", topology) + + configPath := filepath.Join(dir, config.DefaultManifestFile) + + // Step 2: resolve the manifest so we can call Plan and write its output. + planned, err := generate.Plan(generate.PlanOptions{ + ConfigPath: configPath, + ManifestKey: config.DefaultManifestKey, + }) + require.NoError(t, err, "Plan must succeed for topology %s", topology) + + // Step 3: write every planned file into dir so verify can read them. + baseDir := generate.ResolveBaseDir(configPath) + for _, p := range planned { + writePath := p.Path + if !filepath.IsAbs(writePath) { + writePath = filepath.Join(baseDir, writePath) + } + require.NoError(t, os.MkdirAll(filepath.Dir(writePath), 0o755)) + require.NoError(t, os.WriteFile(writePath, []byte(p.Content), 0o644)) + } + + // Step 4: run verify and assert it is clean (nil return). + err = verify.Run(verify.Options{ + ConfigPath: configPath, + ManifestKey: config.DefaultManifestKey, + }, io.Discard, io.Discard) + require.NoError(t, err, "verify must be clean after init+generate for topology %s", topology) +} + +// TestVerifyClean_NoEnv confirms that for a release-only (no-env) scaffold, +// running generate-workflow and then verify produces a clean result. +func TestVerifyClean_NoEnv(t *testing.T) { + verifyShape(t, "no-env") +} + +// TestVerifyClean_FourEnv confirms that for a four-environment scaffold, +// running generate-workflow and then verify produces a clean result. +func TestVerifyClean_FourEnv(t *testing.T) { + verifyShape(t, "four-env") +} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 1edf945..b9dca72 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -27,9 +27,11 @@ import ( 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" + manifestPath = config.DefaultManifestFile + buildPath = ".github/workflows/build.yaml" + deployPath = ".github/workflows/deploy.yaml" + codeownersPath = ".github/CODEOWNERS" + awsOIDCPath = ".github/aws-oidc-role.example.json" ) // scaffoldConfig holds the resolved, optional inputs for a scaffold render. @@ -81,8 +83,10 @@ func Scaffold(project, trunkBranch string, envs []string, opts ...Option) (map[s } files := map[string]string{ - manifestPath: manifest, - buildPath: strings.ReplaceAll(buildStub, "", project), + manifestPath: manifest, + buildPath: strings.ReplaceAll(buildStub, "", project), + codeownersPath: codeowners, + awsOIDCPath: awsOIDCRoleExample, } if len(envs) > 0 { files[deployPath] = strings.ReplaceAll(deployStub, "", project) @@ -233,6 +237,42 @@ jobs: echo "artifact_id=placeholder-${{ inputs.sha }}" >> "$GITHUB_OUTPUT" ` +// codeowners is the starter CODEOWNERS file included in every scaffold. It +// requires review on every change by default. Replace @my-org/my-team with +// the real owning team or users before merging. +const codeowners = `# Require review on every change by default. Replace @my-org/my-team with +# your real owning team or users. See +# https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners +* @my-org/my-team +` + +// awsOIDCRoleExample is a documented example IAM role trust policy for GitHub +// Actions OIDC. The account ID, org, and repo are placeholders; replace them +// before using this policy. Copy this file to your IAM role trust policy in +// the AWS console or via Terraform. +const awsOIDCRoleExample = `{ + "_comment": "EXAMPLE ONLY - not a real policy. Replace 123456789012 with your AWS account ID, my-org with your GitHub org, and my-app with your repo name. The sub condition must match your repo. Action: sts:AssumeRoleWithWebIdentity via GitHub's OIDC provider.", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:my-org/my-app:*" + } + } + } + ] +} +` + // deployStub is the reusable deploy workflow rendered when at least one // environment is requested. Like buildStub, only is substituted and all // ${{ ... }} expressions remain literal. diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index bfb638d..e63ae2c 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -1,6 +1,7 @@ package scaffold import ( + "encoding/json" "os" "path/filepath" "strings" @@ -64,12 +65,16 @@ func TestScaffold_ExpectedFileSet(t *testing.T) { assert.True(t, hasBuild, "build stub always present") _, hasDeploy := files[".github/workflows/deploy.yaml"] + _, hasCodeowners := files[".github/CODEOWNERS"] + _, hasAWSOIDC := files[".github/aws-oidc-role.example.json"] + assert.True(t, hasCodeowners, "CODEOWNERS always present") + assert.True(t, hasAWSOIDC, "aws-oidc-role.example.json always present") if len(tc.envs) > 0 { assert.True(t, hasDeploy, "deploy stub present when envs non-empty") - assert.Len(t, files, 3) + assert.Len(t, files, 5) } else { assert.False(t, hasDeploy, "no deploy stub for release-only") - assert.Len(t, files, 2) + assert.Len(t, files, 4) } }) } @@ -258,3 +263,68 @@ func TestSelfCheck_FailsOnBuildWithoutWorkflow(t *testing.T) { require.Error(t, err) assert.Contains(t, strings.ToLower(err.Error()), "validat") } + +func TestScaffold_IncludesCodeowners(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) + + content, ok := files[".github/CODEOWNERS"] + assert.True(t, ok, "CODEOWNERS must be present for topology %s", tc.name) + assert.NotEmpty(t, content, "CODEOWNERS must be non-empty") + }) + } +} + +func TestScaffold_IncludesAWSOIDCExample(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) + + content, ok := files[".github/aws-oidc-role.example.json"] + assert.True(t, ok, "aws-oidc-role.example.json must be present for topology %s", tc.name) + assert.NotEmpty(t, content, "aws-oidc-role.example.json must be non-empty") + + var v interface{} + require.NoError(t, json.Unmarshal([]byte(content), &v), "aws-oidc-role.example.json must be valid JSON") + assert.Contains(t, content, "sts:AssumeRoleWithWebIdentity") + assert.Contains(t, content, "token.actions.githubusercontent.com") + }) + } +} + +func TestScaffold_DeterministicOutput(t *testing.T) { + first, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + second, err := Scaffold("acme", "main", []string{"dev", "prod"}) + require.NoError(t, err) + + require.Equal(t, len(first), len(second), "map lengths must match") + for k, v1 := range first { + v2, ok := second[k] + require.True(t, ok, "key %s missing from second call", k) + assert.Equal(t, v1, v2, "file %s must be byte-identical across calls", k) + } +} + +func TestScaffold_NoBannedStrings(t *testing.T) { + banned := []string{"cfa", "dxe", "delivery"} + const emDash = "—" + + for _, tc := range topologyCases() { + t.Run(tc.name, func(t *testing.T) { + files, err := Scaffold("acme", "main", tc.envs) + require.NoError(t, err) + + for path, content := range files { + lower := strings.ToLower(content) + for _, word := range banned { + assert.NotContains(t, lower, word, "file %s contains banned string %q", path, word) + } + assert.NotContains(t, content, emDash, "file %s contains an em dash", path) + } + }) + } +}