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
8 changes: 4 additions & 4 deletions e2e/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ func (h *Harness) StageRepoFromConfig(ctx context.Context, config Config, setupW
// A custom changelog workflow is a reusable workflow invoked as a
// job-level uses:. Stub it so the generated changelog job resolves and
// exposes a changelog output for the release step to consume.
if wf, ok := config.Changelog["workflow"].(string); ok && wf != "" {
if p := normalizeCallbackStubPath(wf); p != "" {
if config.Changelog != nil && config.Changelog.Workflow != "" {
if p := normalizeCallbackStubPath(config.Changelog.Workflow); p != "" {
files[p] = generateChangelogStubWorkflow(scenarioTag)
}
}
Expand All @@ -182,8 +182,8 @@ func (h *Harness) StageRepoFromConfig(ctx context.Context, config Config, setupW
// can read the referenced workflow at generation time and emit the validate
// gate. Without a seeded stub the generator fails reading validate.yaml,
// since the file would otherwise only arrive via a later step commit.
if wf, ok := config.Validate["workflow"].(string); ok && wf != "" {
if p := normalizeCallbackStubPath(wf); p != "" {
if config.Validate != nil && config.Validate.Workflow != "" {
if p := normalizeCallbackStubPath(config.Validate.Workflow); p != "" {
files[p] = generateValidateStubWorkflow(scenarioTag)
}
}
Expand Down
196 changes: 13 additions & 183 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"

"gopkg.in/yaml.v3"

"github.com/stablekernel/cascade/internal/config"
)

// Scenario represents a complete E2E test scenario
Expand All @@ -26,189 +28,17 @@ type Setup struct {
Releases []ReleaseSetup `yaml:"releases"`
}

// Config mirrors trunk-config.yaml structure
type Config struct {
TrunkBranch string `yaml:"trunk_branch"`
Environments []string `yaml:"environments"`
JobTimeoutMinutes int `yaml:"job_timeout_minutes,omitempty"`
// ReleaseTrigger carries the release_trigger field through to the generated
// manifest so a scenario can make orchestrate dispatch-only. Without this
// field the value is silently dropped on marshal, the same hazard the token
// fields below document, and the generated orchestrate keeps its push trigger.
ReleaseTrigger string `yaml:"release_trigger,omitempty"`
// ReleaseToken carries the release_token field through to the generated
// manifest. It accepts a full ${{ secrets.* }} expression or a bare secret
// name; the generator normalizes a bare name to a resolvable expression.
ReleaseToken string `yaml:"release_token,omitempty"`
// StateToken carries the state_token field through to the generated manifest,
// the same way ReleaseToken does. Without this field a scenario's state_token
// is silently dropped on marshal, so the generated workflows fall back to the
// default token.
StateToken string `yaml:"state_token,omitempty"`
// ReleaseTokenApp and StateTokenApp carry the optional GitHub App identities
// (app_id, private_key secret references) through to the generated manifest
// untouched. A generic map keeps the harness decoupled from the generator's
// AppTokenSource shape while preserving every key across the marshal
// round-trip.
ReleaseTokenApp map[string]any `yaml:"release_token_app,omitempty"`
StateTokenApp map[string]any `yaml:"state_token_app,omitempty"`
Builds []BuildConfig `yaml:"builds"`
Deploys []DeployConfig `yaml:"deploys"`
Publish *PublishConfig `yaml:"publish,omitempty"`
// Changelog carries the changelog block (custom workflow, contributors)
// through to the generated manifest untouched. A generic map keeps the
// harness decoupled from the generator's ChangelogConfig shape while
// preserving every key across the marshal round-trip.
Changelog map[string]any `yaml:"changelog,omitempty"`
// DispatchInputs carries operator-facing workflow_dispatch inputs through to
// the generated manifest untouched. A generic map (rather than a typed
// struct) is used so the harness stays decoupled from the generator's
// DispatchInput shape while preserving every key (type, options, default,
// description, required) across the marshal round-trip.
DispatchInputs map[string]map[string]any `yaml:"dispatch_inputs,omitempty"`
// EnvironmentConfig carries per-environment settings (gha_environment plus the
// additive required_reviewers, wait_timer, branch_policy, branch_patterns,
// tag_patterns, secrets, and variables fields) into the generated manifest so
// the generator emits the job-level environment: key and the cascade
// environments command can emit the per-env config. A generic map per env keeps
// the harness decoupled from the generator's EnvironmentConfig struct while
// preserving every key across the marshal round-trip, so a scenario can declare
// any per-env field without a harness change. Keyed by env name.
EnvironmentConfig map[string]map[string]any `yaml:"environment_config,omitempty"`
// Validate, ValidateCheck, MergeQueue, PRPreview, Notify, and External carry
// the optional generator features through to the generated manifest untouched.
// Each uses a generic map (rather than a typed struct) so the harness stays
// decoupled from the generator's struct shapes while preserving every key
// across the marshal round-trip. As the generator gains new keys under any of
// these blocks, scenarios can exercise them without a harness change.
Validate map[string]any `yaml:"validate,omitempty"`
ValidateCheck map[string]any `yaml:"validate_check,omitempty"`
MergeQueue map[string]any `yaml:"merge_queue,omitempty"`
PRPreview map[string]any `yaml:"pr_preview,omitempty"`
// DriftCheck carries the opt-in drift-check lane (enabled, comment) through to
// the generated manifest untouched, so a scenario can enable the generated PR
// drift-check workflow and its fork-safe comment companion (#229).
DriftCheck map[string]any `yaml:"drift_check,omitempty"`
// Deployments carries the opt-in native GitHub Deployments block (enabled,
// keep_prior_active) through to the generated manifest untouched, so a
// scenario can enable the finalize-seam Deployments API steps. A generic map
// keeps the harness decoupled from the generator's DeploymentsConfig shape.
Deployments map[string]any `yaml:"deployments,omitempty"`
// Rollback carries the opt-in rollback block (repository_dispatch) through to
// the generated manifest untouched, so a scenario can enable the external
// repository_dispatch trigger on the rollback workflow (#181). A generic map
// keeps the harness decoupled from the generator's RollbackConfig shape.
Rollback map[string]any `yaml:"rollback,omitempty"`
Notify map[string]any `yaml:"notify,omitempty"`
External []map[string]any `yaml:"external,omitempty"`
// Telemetry carries the reserved vendor-neutral telemetry block (enabled,
// adapter, webhook, job_summary) through to the generated manifest untouched.
// A generic map keeps the harness decoupled from the generator's
// TelemetryConfig shape, so a scenario can declare any reserved telemetry
// field without the harness needing to know its structure.
Telemetry map[string]any `yaml:"telemetry,omitempty"`
// Release carries the release block (disabled, tag, version_overrides)
// through to the generated manifest untouched. A generic map keeps the
// harness decoupled from the generator's ReleaseConfig shape, so a scenario
// can declare any reserved release field without the harness needing to know
// its structure.
Release map[string]any `yaml:"release,omitempty"`
// ExtraTriggers carries the optional extra orchestrate triggers (schedule,
// repository_dispatch, workflow_run, merge_group) through to the generated
// manifest untouched, so a scenario can assert each extra on: entry is
// emitted into the orchestrate workflow. A generic map keeps the harness
// decoupled from the generator's ExtraTriggers shape.
ExtraTriggers map[string]any `yaml:"extra_triggers,omitempty"`
// PinMode carries the action pin mode (tag or sha) through to the generated
// manifest so a scenario can assert the sha-pinned uses: form versus the
// default tag form.
PinMode string `yaml:"pin_mode,omitempty"`
// ActionPins carries per-action ref overrides through to the generated
// manifest so a scenario can assert an overridden uses: ref is honored
// regardless of pin mode.
ActionPins map[string]string `yaml:"action_pins,omitempty"`
// CLIVersion carries the cli_version field through to the generated manifest
// so a scenario can fix the setup-cli self-action ref (and, under pin_mode:
// sha, the version comment that trails the pinned SHA).
CLIVersion string `yaml:"cli_version,omitempty"`
// CLIVersionSHA carries the 40-hex commit SHA that cli_version resolves to
// through to the generated manifest. Paired with pin_mode: sha it pins every
// generated setup-cli self-action ref to an immutable commit, so a scenario
// 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
type PublishConfig struct {
Workflow string `yaml:"workflow"`
}

// BuildConfig defines a build component
type BuildConfig struct {
Name string `yaml:"name"`
Workflow string `yaml:"workflow,omitempty"`
Run string `yaml:"run,omitempty"`
Shell string `yaml:"shell,omitempty"`
Triggers []string `yaml:"triggers"`
DependsOn []string `yaml:"depends_on"`
OptionalDependsOn []string `yaml:"optional_depends_on,omitempty"`
TimeoutMinutes int `yaml:"timeout_minutes,omitempty"`
RunsOn any `yaml:"runs_on,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty"`
Concurrency *ConcurrencySpec `yaml:"concurrency,omitempty"`
// Secrets carries the per-callback secrets union (scalar "inherit", the
// mapping {inherit: true}, or a per-secret map) through to the generated
// manifest untouched. A generic value keeps the harness decoupled from the
// generator's SecretsConfig shape while preserving every accepted form across
// the marshal round-trip. Omitted entirely when unset so the generator sees no
// secrets field (the opt-in default emits no secrets block).
Secrets any `yaml:"secrets,omitempty"`
}

// DeployConfig defines a deploy component
type DeployConfig struct {
Name string `yaml:"name"`
Workflow string `yaml:"workflow,omitempty"`
Run string `yaml:"run,omitempty"`
Shell string `yaml:"shell,omitempty"`
Triggers []string `yaml:"triggers"`
DependsOn []string `yaml:"depends_on"`
OptionalDependsOn []string `yaml:"optional_depends_on,omitempty"`
TimeoutMinutes int `yaml:"timeout_minutes,omitempty"`
SupportsDryRun bool `yaml:"supports_dry_run,omitempty"`
RunsOn any `yaml:"runs_on,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty"`
Concurrency *ConcurrencySpec `yaml:"concurrency,omitempty"`
// Secrets carries the per-callback secrets union through to the generated
// manifest untouched. See BuildConfig.Secrets for the accepted forms and the
// rationale for the generic value type.
Secrets any `yaml:"secrets,omitempty"`
// 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"`
}

// ConcurrencySpec defines the per-callback concurrency block written to trunk-config.yaml.
type ConcurrencySpec struct {
Group string `yaml:"group"`
CancelInProgress bool `yaml:"cancel_in_progress"`
}
// Config is the scenario's trunk-config block. It is a direct alias of
// config.TrunkConfig, the cascade CLI's own manifest type, so every field the
// CLI understands is marshalable into a scenario's generated manifest.yaml with
// no parallel struct to keep in sync. The prior hand-mirrored struct silently
// dropped any manifest field nobody remembered to copy across, and each new
// generator feature needed a matching harness edit before a scenario could
// reach it. Reusing the source of truth removes that failure mode: a field
// added to config.TrunkConfig is reachable from a scenario immediately (#386).
// The multi-repo path already carries config.TrunkConfig directly, so
// single-step and multi-step scenarios now match it.
type Config = config.TrunkConfig

// Commit defines a commit to create
type Commit struct {
Expand Down
62 changes: 62 additions & 0 deletions e2e/harness/scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package harness
import (
"os"
"path/filepath"
"reflect"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"

"github.com/stablekernel/cascade/internal/config"
)

func TestParseScenario(t *testing.T) {
Expand Down Expand Up @@ -43,6 +47,64 @@ expect:
assert.Equal(t, "success", scenario.Expect.Workflow.Conclusion)
}

// TestConfigReusesTrunkConfig locks in the harness Config sharing the CLI's own
// manifest type. When these are the same type, every field the CLI parses is
// reachable from a scenario without a parallel struct to hand-maintain, which is
// the whole point of the reuse: a field added to config.TrunkConfig needs no
// harness edit to be marshalable into a scenario's manifest.yaml.
func TestConfigReusesTrunkConfig(t *testing.T) {
assert.Equal(t, reflect.TypeOf(config.TrunkConfig{}), reflect.TypeOf(Config{}),
"harness Config must be config.TrunkConfig so new manifest fields flow through without a hand-edit")
}

// TestConfigCarriesFieldWithoutHarnessEdit proves the regression the reuse
// closes: a manifest field that the retired hand-mirrored struct never listed is
// now parsed from a scenario and marshaled back into the generated ci.config
// block with no harness change. tag_prefix stands in for any such field. It was
// absent from the old parallel struct, so before the reuse it was silently
// dropped; now it round-trips because the harness marshals the CLI's own type.
func TestConfigCarriesFieldWithoutHarnessEdit(t *testing.T) {
const scenarioYAML = `
name: "Field reach"
description: "tag_prefix survives the marshal round-trip"
setup:
config:
trunk_branch: main
tag_prefix: component-
environments:
- dev
trigger:
workflow: orchestrate.yaml
event: push
expect:
workflow:
conclusion: success
`
scenario, err := ParseScenario([]byte(scenarioYAML))
require.NoError(t, err)
require.Equal(t, "component-", scenario.Setup.Config.TagPrefix,
"scenario YAML must parse the field the old struct dropped")

// Mirror how the harness writes manifest.yaml: the config under ci.config.
manifest := map[string]any{
"ci": map[string]any{
"config": scenario.Setup.Config,
},
}
out, err := yaml.Marshal(manifest)
require.NoError(t, err)
assert.Contains(t, string(out), "tag_prefix: component-",
"the field must reach the generated manifest without a harness edit")

// The config block must parse back into the CLI's type unchanged, so the
// field is not merely emitted but actually consumed as the CLI sees it.
configOut, err := yaml.Marshal(scenario.Setup.Config)
require.NoError(t, err)
var roundTrip config.TrunkConfig
require.NoError(t, yaml.Unmarshal(configOut, &roundTrip))
assert.Equal(t, "component-", roundTrip.TagPrefix)
}

func TestDiscoverScenarios(t *testing.T) {
// Create temp directory with test scenarios
dir := t.TempDir()
Expand Down
Loading