diff --git a/e2e/harness/harness.go b/e2e/harness/harness.go index 977a68d..ba329d8 100644 --- a/e2e/harness/harness.go +++ b/e2e/harness/harness.go @@ -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) } } @@ -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) } } diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index 2e78bf4..871dac1 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -6,6 +6,8 @@ import ( "strings" "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" ) // Scenario represents a complete E2E test scenario @@ -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 { diff --git a/e2e/harness/scenario_test.go b/e2e/harness/scenario_test.go index 7c53c88..4d820f4 100644 --- a/e2e/harness/scenario_test.go +++ b/e2e/harness/scenario_test.go @@ -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) { @@ -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()