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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions internal/initcmd/verify_clean_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
50 changes: 45 additions & 5 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, "<name>", project),
manifestPath: manifest,
buildPath: strings.ReplaceAll(buildStub, "<name>", project),
codeownersPath: codeowners,
awsOIDCPath: awsOIDCRoleExample,
}
if len(envs) > 0 {
files[deployPath] = strings.ReplaceAll(deployStub, "<name>", project)
Expand Down Expand Up @@ -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 <name> is substituted and all
// ${{ ... }} expressions remain literal.
Expand Down
74 changes: 72 additions & 2 deletions internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package scaffold

import (
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
}
})
}
Expand Down Expand Up @@ -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)
}
})
}
}
Loading