Skip to content

feat(config): uniform deep-merge for component inheritance#565

Merged
joshua-temple merged 3 commits into
mainfrom
feat/uniform-deep-merge
Jul 11, 2026
Merged

feat(config): uniform deep-merge for component inheritance#565
joshua-temple merged 3 commits into
mainfrom
feat/uniform-deep-merge

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem: component inheritance whole-replaced each block, so a partial override (a component setting only environment_config.prod, or only tag_grammar.prefix) silently dropped every inherited sibling. The demo manifest already documented this as "additive" but the code did not merge.

Fix: ResolveComponent now applies a uniform field-by-field deep merge of the component overrides onto the shared top-level defaults:

  • Nested blocks merge recursively (only tag_grammar.prefix set keeps the inherited prerelease_token/separator/dryrun_token).
  • Keyed maps such as environment_config merge by key and field-by-field within a key (only prod set keeps shared dev/staging, and a prod entry that sets only wait_timer keeps the inherited gha_environment).
  • Scalars and lists still replace outright.
  • extra_paths/shared_paths keep their existing union.

The merge is a generic-JSON round trip over the same fidelity contract clone() already relies on; omitempty on every override field means an unset key is absent from the patch and never clobbers the inherited value.

Verification:

  • New unit tests (components_deepmerge_test.go) fail before / pass after: partial environment_config and tag_grammar overrides now inherit siblings; guards prove extra_paths/shared_paths still union and a full block override still wins.
  • Golden output: no committed fixture does a partial component-level nested override, so the whole generate suite (and every golden) is byte-identical; confirmed by the full go test ./... staying green. The one behavior change is exactly what a partial override should now produce.
  • Added e2e scenario 57-component-partial-inherit.yaml: top-level deployments.enabled: true with a component setting only deployments.keep_prior_active. Verified by generating locally that this now emits deployment reporting with auto_inactive=false for the partial-override component and auto_inactive=true for the plain inheritor; under whole-replace the partial-override component emitted no deployment step at all (its inherited enabled was dropped). Drift-free roundtrip asserted.
  • go build, go test ./..., go test -race ./..., and golangci-lint run ./... clean on both the root and the e2e module.
  • Docs updated: reference/manifest.md documents the deep-merge rule per field; the components guide points to it.

Component overrides merged whole-replace into the shared top-level defaults, so a partial override (only environment_config.prod, or only tag_grammar.prefix) silently dropped every inherited sibling. Replace it with a uniform field-by-field deep merge: an unset override inherits, a scalar or list replaces, and a nested block or keyed map merges key-by-key. extra_paths/shared_paths keep their existing union.

The merge is a generic-JSON round trip over the same fidelity contract clone() already relies on; omitempty on every override field means an unset key is absent and never clobbers the inherited value.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
The deep merge builds the override patch from the marshalled component, and a bare bool/int with omitempty marshals its zero value to nothing. A component that set an inherited boolean back to false (for example deployments.enabled: false under a shared enabled: true) therefore merged nothing and silently kept the inherited true.

Pointer-ize the inheritable bool/int override fields so nil (unset) is distinct from an explicit false or 0, matching the AllowBreakingChanges and TelemetryConfig.JobSummary precedent: DeploymentsConfig.{Enabled,KeepPriorActive}, ReleaseConfig.Disabled, ChangelogConfig.{Disabled,Contributors}, PRPreviewConfig.{Enabled,Comment}, ValidateCheckConfig.Enabled, ValidateConfig.{SupportsDryRun,Retries}, EnvironmentConfig.WaitTimer, and DispatchInput.Required. Readers go through nil-safe accessor methods that centralize the unset default. Slice-element scalars (builds, deploys, external) are unaffected because a component list replaces the shared list wholesale.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
The uniform field-merge corrupted XOR-exclusive and atomic blocks by composing one component's fields onto the inherited block's fields. The reachable security case: a shared validate.secrets: inherit plus a component narrowing to an explicit allow-list resolved to Inherit AND Map set; generation gives inherit precedence and emitted secrets: inherit, silently broadening the component's least-privilege scope.

Add a replace-leaf set to the merge core: secrets (SecretsConfig, inherit XOR allow-list, custom decoder), runs_on (RunsOn, scalar|list|object polymorph, custom decoder), and release_token_app / state_token_app (AppTokenSource, atomic app_id+private_key credential). These whole-replace on override instead of field-merging, matching XOR/atomic semantics and the prior whole-replace behavior. A comment documents the class so a future exclusive block is added rather than silently corrupted.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple joshua-temple merged commit 3e656d6 into main Jul 11, 2026
20 checks passed
@joshua-temple joshua-temple deleted the feat/uniform-deep-merge branch July 11, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant