Skip to content

fix(state): preserve state.components on flat single-component write - #630

Merged
joshua-temple merged 1 commit into
mainfrom
fix/statemerge-component-wipe
Jul 18, 2026
Merged

fix(state): preserve state.components on flat single-component write#630
joshua-temple merged 1 commit into
mainfrom
fix/statemerge-component-wipe

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

The flat state map (CICDFile.State, map[string]*EnvState) models environments only. Parsing a component-scoped manifest lifts state.components into that map as a bogus, empty EnvState keyed components. WriteManifestState then rebuilds the whole state node from that map, which collapses state.components to {} and silently destroys every recorded per-component row while the write returns success. Via CommitWithRetry this can land on trunk.

Reproduced executably: a component-scoped manifest with state.components.api.{staging,prod} rows, run through ParseManifestBytes then WriteManifestState (exactly what a finalize invoked without --component runs), emits state.components: {} with all rows gone.

Reachability

Not generator-reachable. Generated per-component workflows always emit --component (the single-component workflow is the only one that omits it), and the finalize dispatch routes any --component invocation to the node-patching WriteScopedState, which preserves siblings. The flat wipe is reachable only via a stale pre-migration flat workflow (a repo that added components but kept an old non-component workflow) or a manual invocation. No runtime guard refuses component == "" on a component manifest. Verdict: a Major silent-corruption hazard, narrower than a generator-reachable Blocker, but a real path to silent trunk state loss.

Fix

Root-cause fix at the single write point, covering every flat-path caller (promote, hotfix, rollback, reset, orchestrate, external) at once. components is now a reserved, unowned subtree on the single-component write path: it is never rebuilt from the flat map, and any existing state.components subtree is preserved verbatim across the rebuild. Chosen over a per-caller refuse because it is provably non-destructive, keeps byte-identity, and does not risk breaking legitimate flat operations on component manifests.

Verification

  • New regression test drives the real production path and fails before the fix, passes after.
  • Byte-identity pinned for a single-component manifest (no components: subtree) against the whole-node-replace oracle.
  • Sibling-preservation pinned for a --component write.
  • go build ./..., go test ./... (3450 pass), -race on state/finalize packages, golangci-lint run ./... all clean. e2e module builds and vets. cascade verify --own-repo: no drift.

No e2e scenario: the generated-workflow harness always passes --component, so it cannot naturally express a flat finalize on a component manifest; the unit test at the real write path is the correct coverage.

The flat state map models environments only, so parsing a component-scoped
manifest lifted state.components into it as an empty entry keyed "components".
Rebuilding the whole state node from that map collapsed state.components to {}
and silently dropped every recorded component row while the write returned
success. A finalize invoked without --component on such a manifest could land
that loss on trunk.

Treat components as a reserved, unowned subtree on the single-component write
path: never rebuild it from the flat map, and preserve any existing subtree
verbatim. A single-component manifest with no components subtree emits
byte-identical output.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit d35d279 into main Jul 18, 2026
21 checks passed
@joshua-temple
joshua-temple deleted the fix/statemerge-component-wipe branch July 18, 2026 05:49
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