diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 623c5405..64380c92 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -178,6 +178,11 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Per-environment settings keyed by environment name." + }, + "components": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/componentConfig" }, + "description": "Reserved per-component descriptor map keyed by component name (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior." } } }, @@ -507,6 +512,15 @@ "dir": { "type": "string", "description": "Directory holding override files. Relative, no '..' segments. Empty means the implementation default (reserved)." } } }, + "componentConfig": { + "type": "object", + "additionalProperties": false, + "description": "Reserved per-component descriptor for independently versioned components sharing one manifest (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior.", + "properties": { + "path": { "type": "string", "description": "Subtree this component owns within the repo (reserved)." }, + "tag_prefix": { "type": "string", "description": "Per-component version tag prefix (reserved). Empty inherits the manifest-level tag_prefix." } + } + }, "changelogConfig": { "type": "object", "additionalProperties": false, diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 2f6999e2..2afacd0c 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -94,6 +94,11 @@ type Step struct { // a per-file unified diff of committed-vs-planned workflows and always exits 0 // on success, exercising plan's informational (non-gate) contract. Plan *PlanStep `yaml:"plan,omitempty"` + // Consistency configures a "consistency" action: a `cascade status + // consistency` run (optionally --fix) that flags, and with --fix deletes, + // orphan env/* branches on the Gitea remote, then asserts the JSON report and + // the resulting remote branch set. + Consistency *ConsistencyStep `yaml:"consistency,omitempty"` // ExpectFailure marks a step whose workflow is expected to conclude in // failure (for example an orchestrate run whose build exits non-zero). When // set, a failure conclusion is the success path and a success conclusion is @@ -258,6 +263,22 @@ type PlanStep struct { ExpectNotContains []string `yaml:"expect_not_contains,omitempty"` } +// ConsistencyStep defines a "consistency" action: a `cascade status consistency` +// run against the synced repo whose origin is the Gitea remote. SeedBranches are +// created on the remote before the run so the command observes them as remote +// branches. With Fix the command deletes each orphan via `git push +// --delete`, so the step exercises the real, strictly-git deletion path end to +// end. The Expect* fields assert the JSON report (orphan and healed lists) and +// the live remote branch set after the run. +type ConsistencyStep struct { + SeedBranches []string `yaml:"seed_branches,omitempty"` + Fix bool `yaml:"fix,omitempty"` + ExpectOrphans []string `yaml:"expect_orphans,omitempty"` + ExpectHealed []string `yaml:"expect_healed,omitempty"` + ExpectBranchesAbsent []string `yaml:"expect_branches_absent,omitempty"` + ExpectBranchesPresent []string `yaml:"expect_branches_present,omitempty"` +} + // StepExpect defines expected outcomes for a step type StepExpect struct { State map[string]*StateExpect `yaml:"state,omitempty"` diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index dac317ca..976a142b 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -3,6 +3,7 @@ package harness import ( "bytes" "context" + "encoding/json" "fmt" "io" "strings" @@ -138,6 +139,10 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.Plan.MutatePath != "" && step.Plan.MutateAppend == "" { return fmt.Errorf("step %d (%s): plan mutate_path requires mutate_append", i, step.Name) } + case "consistency": + if step.Consistency == nil { + return fmt.Errorf("step %d (%s): consistency action requires consistency config", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -376,6 +381,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executeVerify(ctx, step.Verify) case "plan": return r.executePlan(ctx, step.Plan) + case "consistency": + return r.executeConsistency(ctx, step.Consistency) default: return fmt.Errorf("unknown action: %s", step.Action) } @@ -557,6 +564,162 @@ func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } +// consistencyReport mirrors the JSON shape printed by `cascade status +// consistency --json`. Only the fields the harness asserts on are modeled. +type consistencyReport struct { + OrphanEnvBranches []string `json:"orphan_env_branches"` + HealedEnvBranches []string `json:"healed_env_branches"` +} + +// parseConsistencyJSON extracts the report object from command stdout. The +// command prints a single JSON object; the object spans from the first '{' to +// the last '}', so any non-JSON preamble the container exec emits is skipped. +func parseConsistencyJSON(out string) (consistencyReport, error) { + start := strings.Index(out, "{") + end := strings.LastIndex(out, "}") + if start < 0 || end < start { + return consistencyReport{}, fmt.Errorf("no JSON object in output") + } + var report consistencyReport + if err := json.Unmarshal([]byte(out[start:end+1]), &report); err != nil { + return consistencyReport{}, err + } + return report, nil +} + +// assertStringSetEqual reports an error when got and want differ as sets, +// ignoring order. The command emits branches in remote-listing order, which is +// not contractually stable, so the assertion compares membership. +func assertStringSetEqual(label string, got, want []string) error { + gotSet := make(map[string]struct{}, len(got)) + for _, g := range got { + gotSet[g] = struct{}{} + } + if len(gotSet) != len(want) { + return fmt.Errorf("%s: got %v, want %v", label, got, want) + } + for _, w := range want { + if _, ok := gotSet[w]; !ok { + return fmt.Errorf("%s: got %v, want %v", label, got, want) + } + } + return nil +} + +// executeConsistency runs `cascade status consistency` (optionally --fix) in the +// synced repo whose origin is the Gitea remote, then asserts the JSON report and +// the live remote branch set. SeedBranches are created on the remote first so +// the command observes them. With Fix the command deletes each orphan via +// `git push --delete`, exercising the real strictly-git deletion path. +func (r *Runner) executeConsistency(ctx context.Context, step *ConsistencyStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Logf(" Would run cascade status consistency (no harness)") + return nil + } + + // Seed the requested env/* branches on the remote from current trunk HEAD so + // the command lists them. CreateBranch starts the branch at the given commit. + if len(step.SeedBranches) > 0 { + headSHA, err := r.harness.gitea.getHeadSHA(ctx, r.harness.repo) + if err != nil { + return fmt.Errorf("consistency: get HEAD SHA: %w", err) + } + for _, b := range step.SeedBranches { + if err := r.harness.gitea.CreateBranch(ctx, r.harness.repo, b, headSHA); err != nil { + return fmt.Errorf("consistency: seed branch %s: %w", b, err) + } + } + } + + // Sync so /tmp/repo's origin remote-tracking refs include the seeded env/* + // branches; the command lists refs/remotes/origin/* via git for-each-ref. + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("consistency: failed to sync repo: %w", err) + } + + args := "/usr/local/bin/cascade status consistency --json" + if step.Fix { + args += " --fix" + } + cmd := []string{"bash", "-c", "cd /tmp/repo && " + args} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, cmd) + if err != nil { + return fmt.Errorf("consistency: exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + r.t.Logf(" Consistency: exit=%d: %s", exitCode, out.String()) + if exitCode != 0 { + return fmt.Errorf("consistency: expected exit 0, got %d: %s", exitCode, out.String()) + } + + report, err := parseConsistencyJSON(out.String()) + if err != nil { + return fmt.Errorf("consistency: parse JSON (%q): %w", out.String(), err) + } + if err := assertStringSetEqual("orphan_env_branches", report.OrphanEnvBranches, step.ExpectOrphans); err != nil { + return fmt.Errorf("consistency: %w", err) + } + if step.Fix { + if err := assertStringSetEqual("healed_env_branches", report.HealedEnvBranches, step.ExpectHealed); err != nil { + return fmt.Errorf("consistency: %w", err) + } + } + + // Assert the live remote branch set after the run. Query the remote's git + // refs directly via ls-remote: this is the same git layer the command lists + // and deletes through, and it reflects a just-created or just-deleted ref + // immediately, unlike Gitea's higher-level branches API which can lag. + if len(step.ExpectBranchesAbsent) > 0 || len(step.ExpectBranchesPresent) > 0 { + lsCmd := []string{"bash", "-c", "cd /tmp/repo && git ls-remote --heads origin"} + lsExit, lsReader, err := r.harness.act.Container().Exec(ctx, lsCmd) + if err != nil { + return fmt.Errorf("consistency: ls-remote exec failed: %w", err) + } + var lsOut bytes.Buffer + if lsReader != nil { + _, _ = io.Copy(&lsOut, lsReader) + } + if lsExit != 0 { + return fmt.Errorf("consistency: ls-remote failed (exit %d): %s", lsExit, lsOut.String()) + } + present := parseRemoteHeads(lsOut.String()) + r.t.Logf(" Consistency: remote heads after run: %v", present) + for _, b := range step.ExpectBranchesAbsent { + if _, ok := present[b]; ok { + return fmt.Errorf("consistency: branch %s expected deleted but still present on remote", b) + } + } + for _, b := range step.ExpectBranchesPresent { + if _, ok := present[b]; !ok { + return fmt.Errorf("consistency: branch %s expected present but missing on remote", b) + } + } + } + return nil +} + +// parseRemoteHeads parses `git ls-remote --heads` output into a set of branch +// names. Each line is "\trefs/heads/"; non-matching lines are +// skipped. +func parseRemoteHeads(out string) map[string]struct{} { + const prefix = "refs/heads/" + heads := make(map[string]struct{}) + for _, line := range strings.Split(out, "\n") { + idx := strings.Index(line, prefix) + if idx < 0 { + continue + } + name := strings.TrimSpace(line[idx+len(prefix):]) + if name != "" { + heads[name] = struct{}{} + } + } + return heads +} + // executeCommit creates a commit func (r *Runner) executeCommit(ctx context.Context, commit *CommitStep) error { // Track commit reference diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index fe60126b..2e78bf42 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -137,6 +137,12 @@ type Config struct { // can assert the field survives a routine state write rather than being // dropped on finalize. CLIVersionSHA string `yaml:"cli_version_sha,omitempty"` + // Components carries the reserved per-component descriptor map (config.components, + // #176) through to the generated manifest untouched. A generic map per component + // keeps the harness decoupled from the generator's ComponentConfig shape, so a + // scenario can declare any reserved component field (path, tag_prefix) without a + // harness change. Keyed by component name. + Components map[string]map[string]any `yaml:"components,omitempty"` } // PublishConfig defines a publish callback invoked after a release is published @@ -184,10 +190,17 @@ type DeployConfig struct { // manifest untouched. See BuildConfig.Secrets for the accepted forms and the // rationale for the generic value type. Secrets any `yaml:"secrets,omitempty"` - // Rollout carries the rollout sub-block (type, canary, blue_green) through to - // the generated manifest untouched. A generic map keeps the harness decoupled - // from the generator's RolloutConfig shape, so a scenario can declare any - // reserved rollout field without the harness needing to know its structure. + // Inputs carries the deploy callback's matrix inputs through to the generated + // manifest untouched. A non-empty inputs map moves the deploy onto the + // matrix-based promote job, which is where the rollout strategy options + // (fail-fast, max-parallel) render. A generic value type keeps the harness + // decoupled from the generator's input shapes. + Inputs map[string]any `yaml:"inputs,omitempty"` + // Rollout carries the rollout sub-block (type, canary, blue_green, plus the + // strategy knobs max_parallel and fail_fast) through to the generated manifest + // untouched. A generic map keeps the harness decoupled from the generator's + // RolloutConfig shape, so a scenario can declare any rollout field without the + // harness needing to know its structure. Rollout map[string]any `yaml:"rollout,omitempty"` } diff --git a/e2e/scenarios/42-status-consistency-fix.yaml b/e2e/scenarios/42-status-consistency-fix.yaml new file mode 100644 index 00000000..fee71566 --- /dev/null +++ b/e2e/scenarios/42-status-consistency-fix.yaml @@ -0,0 +1,53 @@ +name: "Status Consistency Fix Deletes Orphan Env Branch" +description: | + cascade status consistency --fix deletes env/* integration branches that have + no matching divergence in the manifest, and never touches a branch backing a + genuinely diverged environment. The deletion is strictly git: it lists the + remote's branches and runs git push --delete on each orphan, with no + GitHub-API path. + + This scenario seeds two env branches on the remote: env/dev is an orphan + (dev has no diverged state) and env/prod is healthy (prod carries a diverged + state via a staged integration ref). Running status consistency --fix flags + and deletes env/dev while leaving env/prod intact. The JSON report names + env/dev as both the orphan and the healed branch, and the live remote ends + with env/dev gone and env/prod present. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["src/**"] + +setup: + state: + prod: + version: "v0.1.0" + ref: env/prod + +steps: + - name: "Seed a minimal source tree" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + + - name: "consistency --fix deletes the orphan, spares the diverged env branch" + action: consistency + consistency: + seed_branches: [env/dev, env/prod] + fix: true + expect_orphans: [env/dev] + expect_healed: [env/dev] + expect_branches_absent: [env/dev] + expect_branches_present: [env/prod] diff --git a/e2e/scenarios/43-deploy-rollout-strategy.yaml b/e2e/scenarios/43-deploy-rollout-strategy.yaml new file mode 100644 index 00000000..b153e551 --- /dev/null +++ b/e2e/scenarios/43-deploy-rollout-strategy.yaml @@ -0,0 +1,68 @@ +name: "Deploy Rollout Strategy Options" +description: | + A matrix-based deploy that sets the rollout strategy knobs max_parallel and + fail_fast must render them into the generated promote workflow's deploy job + strategy block. The matrix path is taken when the deploy declares inputs. The + fail-fast default is false and no max-parallel line is emitted without a + rollout, so a deploy that sets fail_fast: true and max_parallel: 2 proves the + configured values flow through rather than the historical defaults. + + Generator-output assertion only; no workflow run. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["src/**"] + inputs: + region: us-east-1 + rollout: + max_parallel: 2 + fail_fast: true + +# The deploy callback is staged so it declares the region matrix input the +# generated promote workflow threads through each rollout wave, keeping the +# generated reusable-workflow call valid. +setup_workflows: + ".github/workflows/deploy.yaml": | + name: deploy + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + region: + required: false + type: string + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo "deploy ${{ inputs.region }}" + +steps: + - name: "Seed a minimal source tree; assert the promote strategy block" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + expect: + workflow_files: + - path: ".github/workflows/promote.yaml" + contains: + - "fail-fast: true" + - "max-parallel: 2" diff --git a/e2e/scenarios/44-components-reserved.yaml b/e2e/scenarios/44-components-reserved.yaml new file mode 100644 index 00000000..0004ee89 --- /dev/null +++ b/e2e/scenarios/44-components-reserved.yaml @@ -0,0 +1,44 @@ +name: "Components Reserved Shape" +description: | + Exercises the reserved per-component descriptor map (config.components, #176). + Each component carries a path subtree and an optional tag_prefix. This block is + reserved and shape-only today: it parses and passes structural validation, but + carries no generator, state, or runtime behavior. The scenario declares two + components, generates the workflows, then regenerates and proves the output is + byte-identical with no drift. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["src/**"] + components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- + +steps: + - name: "Seed a minimal source tree" + action: commit + commit: + message: "seed source" + files: + src/main.go: | + package main + + func main() {} + + - name: "Regenerate and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 623c5405..64380c92 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -178,6 +178,11 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Per-environment settings keyed by environment name." + }, + "components": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/componentConfig" }, + "description": "Reserved per-component descriptor map keyed by component name (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior." } } }, @@ -507,6 +512,15 @@ "dir": { "type": "string", "description": "Directory holding override files. Relative, no '..' segments. Empty means the implementation default (reserved)." } } }, + "componentConfig": { + "type": "object", + "additionalProperties": false, + "description": "Reserved per-component descriptor for independently versioned components sharing one manifest (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior.", + "properties": { + "path": { "type": "string", "description": "Subtree this component owns within the repo (reserved)." }, + "tag_prefix": { "type": "string", "description": "Per-component version tag prefix (reserved). Empty inherits the manifest-level tag_prefix." } + } + }, "changelogConfig": { "type": "object", "additionalProperties": false, diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 623c5405..64380c92 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -178,6 +178,11 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Per-environment settings keyed by environment name." + }, + "components": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/componentConfig" }, + "description": "Reserved per-component descriptor map keyed by component name (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior." } } }, @@ -507,6 +512,15 @@ "dir": { "type": "string", "description": "Directory holding override files. Relative, no '..' segments. Empty means the implementation default (reserved)." } } }, + "componentConfig": { + "type": "object", + "additionalProperties": false, + "description": "Reserved per-component descriptor for independently versioned components sharing one manifest (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior.", + "properties": { + "path": { "type": "string", "description": "Subtree this component owns within the repo (reserved)." }, + "tag_prefix": { "type": "string", "description": "Per-component version tag prefix (reserved). Empty inherits the manifest-level tag_prefix." } + } + }, "changelogConfig": { "type": "object", "additionalProperties": false,