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
14 changes: 14 additions & 0 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <remote>
// --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"`
Expand Down
163 changes: 163 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package harness
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 <remote> --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 "<sha>\trefs/heads/<branch>"; 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
Expand Down
21 changes: 17 additions & 4 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}

Expand Down
53 changes: 53 additions & 0 deletions e2e/scenarios/42-status-consistency-fix.yaml
Original file line number Diff line number Diff line change
@@ -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 <remote> --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]
Loading
Loading