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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ A `Migration` section is added to any release that bumps `schema_version`.

### Fixed

- **promote:** Record only the publishing component's own release marker
(`version`, `sha`, `released_on`) under `latest_release.components.<name>`.
Previously a component publish wrote the entire shared release record under
its own leaf, nesting a stale snapshot of every component's markers there,
and a component promotion save could synthesize a phantom marker for a
component that had never released

- **config:** Validate `run_policy`, `on_failure`, and `retries` on the
`validate` block the same way as builds and deploys: `run_policy` and
`on_failure` must be one of their documented values and `retries` must stay
Expand Down
15 changes: 14 additions & 1 deletion internal/config/statemerge.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,27 @@ func applyComponentStateLeaf(section *yaml.Node, w StateWrite) error {
}

// applyComponentLatestLeaf sets or deletes latest_release.components.<Component>.
//
// The leaf is the component's OWN release marker, so the directive's flat
// version/sha/released_on fields are projected into the ComponentReleaseState
// shape the typed readers consume (ParseManifestFile populates
// LatestRelease.Components[name] from exactly these keys). Callers hand over
// the shared LatestReleaseState they already maintain; marshaling that record
// whole here would nest its per-component Components map (a stale snapshot of
// every component's marker) and the top-level-only released_by under the
// publishing component's leaf on every write.
func applyComponentLatestLeaf(section *yaml.Node, w StateWrite) error {
if w.Latest == nil {
if comps := existingChild(section, "latest_release", "components"); comps != nil {
deleteMappingKey(comps, w.Component)
}
return nil
}
node, err := valueNode(w.Latest)
node, err := valueNode(&ComponentReleaseState{
Version: w.Latest.Version,
SHA: w.Latest.SHA,
ReleasedOn: w.Latest.ReleasedOn,
})
if err != nil {
return fmt.Errorf("encoding component latest_release for state write: %w", err)
}
Expand Down
60 changes: 58 additions & 2 deletions internal/config/statemerge_component_latest_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -78,8 +79,13 @@ func TestWriteScopedState_ComponentLatestSetWritesOwnLeafOnly(t *testing.T) {
if api["version"] != "api-v1.1.0" || api["sha"] != "apirel2" {
t.Errorf("api marker = %v, want version api-v1.1.0 / sha apirel2", api)
}
if api["released_on"] != "2026-01-01T00:00:00Z" || api["released_by"] != "octocat" {
t.Errorf("api marker metadata = %v, want released_on/released_by carried through", api)
if api["released_on"] != "2026-01-01T00:00:00Z" {
t.Errorf("api marker metadata = %v, want released_on carried through", api)
}
// released_by is a top-level-only field of the shared record; the
// ComponentReleaseState leaf shape does not carry it.
if _, has := api["released_by"]; has {
t.Errorf("api marker carries top-level-only released_by: %v", api)
}

// The sibling's marker survives verbatim, unmodeled key included.
Expand All @@ -106,6 +112,56 @@ func TestWriteScopedState_ComponentLatestSetWritesOwnLeafOnly(t *testing.T) {
}
}

// TestWriteScopedState_ComponentLatestLeafIsComponentShaped drives the exact
// directive shape production hands over: the finalizer passes the SHARED
// LatestReleaseState, whose flat fields hold this publish and whose Components
// map holds every component's recorded marker. The written leaf must be the
// component's OWN marker only (the ComponentReleaseState shape the typed reader
// consumes: version/sha/released_on). It must never nest a components map, a
// sibling's marker, or the top-level-only released_by under the leaf.
func TestWriteScopedState_ComponentLatestLeafIsComponentShaped(t *testing.T) {
got, err := WriteScopedState([]byte(componentLatestManifest), "ci",
StateWrite{Component: "api", Latest: &LatestReleaseState{
Version: "api-v1.1.0",
SHA: "apirel2",
ReleasedOn: "2026-01-01T00:00:00Z",
ReleasedBy: "octocat",
Components: map[string]*ComponentReleaseState{
"api": {Version: "api-v1.0.0", SHA: "apirel1"},
"web": {Version: "web-v2.0.0", SHA: "webrel1"},
},
}})
if err != nil {
t.Fatalf("WriteScopedState: %v", err)
}

comps := latestComponents(t, got)
api, ok := comps["api"].(map[string]any)
if !ok {
t.Fatalf("latest_release.components.api missing:\n%s", got)
}
if _, nested := api["components"]; nested {
t.Errorf("component leaf nests a components map (shared record written whole):\n%s", got)
}
want := map[string]any{
"version": "api-v1.1.0",
"sha": "apirel2",
"released_on": "2026-01-01T00:00:00Z",
}
if !reflect.DeepEqual(api, want) {
t.Errorf("api leaf = %v, want exactly the component marker %v", api, want)
}

// The sibling's own top-level marker is untouched.
web, ok := comps["web"].(map[string]any)
if !ok {
t.Fatalf("sibling latest_release.components.web dropped:\n%s", got)
}
if web["version"] != "web-v2.0.0" || web["sha"] != "webrel1" {
t.Errorf("web marker = %v, want verbatim web-v2.0.0 / webrel1", web)
}
}

// TestWriteScopedState_ComponentLatestSetSynthesizesContainer covers the first
// publish of a component on a manifest with no latest_release node at all: the
// latest_release.components.<component> path is created.
Expand Down
214 changes: 214 additions & 0 deletions internal/promote/finalize_component_latest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package promote

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stablekernel/cascade/internal/config"
"github.com/stretchr/testify/require"
)

// publishedComponentManifest seeds recorded release markers for two components
// the way real publish history leaves them: api has released before, and web's
// marker carries an unmodeled key so verbatim preservation is provable.
const publishedComponentManifest = `ci:
config:
environments: [dev, prod]
components:
api:
path: services/api
tag_grammar:
prefix: api-v
web:
path: services/web
tag_grammar:
prefix: web-v
state:
components:
api:
dev:
sha: apirel2
version: api-v1.1.0-rc.0
prod:
sha: apirel1
version: api-v1.0.0
latest_release:
components:
api:
version: api-v1.0.0
sha: apirel1
released_on: "2026-01-01T00:00:00Z"
web:
version: web-v2.0.0
sha: webrel1
custom_marker: keep-me-verbatim
`

// latestComponentsNode digs ci.latest_release.components out of a parsed
// manifest tree, failing the test when any level is missing.
func latestComponentsNode(t *testing.T, m map[string]any) map[string]any {
t.Helper()
ci, ok := m["ci"].(map[string]any)
require.True(t, ok, "ci block present")
latest, ok := ci["latest_release"].(map[string]any)
require.True(t, ok, "ci.latest_release present")
comps, ok := latest["components"].(map[string]any)
require.True(t, ok, "ci.latest_release.components present")
return comps
}

// TestFinalizer_ComponentPublish_WritesOwnMarkerNotSharedRecord is the
// regression test for the component publish marker leak: the finalizer's
// latest_release directive carried the whole shared record, so every publish
// after the repo's first nested a stale snapshot of ALL components' markers
// (its own prior one included) under latest_release.components.<component>.
// The leaf must hold exactly the publishing component's own marker.
func TestFinalizer_ComponentPublish_WritesOwnMarkerNotSharedRecord(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
require.NoError(t, os.WriteFile(path, []byte(publishedComponentManifest), 0644))

fin, err := NewFinalizer(path, "prod", WithComponent("api"), WithClock(fixedClock()))
require.NoError(t, err)
fin.SetActor("octocat")
fin.SetPromotionResult(&PromotionResult{
Promotions: []EnvPromotion{{Environment: "prod", SourceEnv: "dev", SHA: "apirel2", Version: "api-v1.1.0"}},
ReleaseAction: "publish",
ReleaseData: &ReleaseData{SHA: "apirel2", RCVersion: "api-v1.1.0-rc.0", SemVersion: "api-v1.1.0"},
})

require.NoError(t, fin.Run())

out, err := os.ReadFile(path)
require.NoError(t, err)
comps := latestComponentsNode(t, readManifestNode(t, out))

api, ok := comps["api"].(map[string]any)
require.True(t, ok, "latest_release.components.api present")

// The leaf is the component's own marker: this publish's version/sha plus
// the audit timestamp, and nothing else.
require.Equal(t, "api-v1.1.0", api["version"])
require.Equal(t, "apirel2", api["sha"])
require.Equal(t, "2026-07-08T12:00:00Z", api["released_on"])

// The corruption shape: the shared record's per-component map (stale self +
// sibling markers) must never nest under the component's own leaf.
_, nested := api["components"]
require.False(t, nested,
"publish nested the shared latest_release record (all components' markers) under api's leaf:\n%s", out)
require.Len(t, api, 3, "api leaf must hold exactly version/sha/released_on, got: %#v", api)

// The sibling's marker survives verbatim, unmodeled key included.
web, ok := comps["web"].(map[string]any)
require.True(t, ok, "sibling web marker present")
require.Equal(t, "web-v2.0.0", web["version"])
require.Equal(t, "webrel1", web["sha"])
require.Contains(t, string(out), "custom_marker: keep-me-verbatim")
}

// TestFinalizer_ComponentPublish_MarkerRoundTripsToChangelogRead proves the
// write shape is exactly the read shape: the marker a component publish
// records must resolve through the typed path
// calculateComponentChangelogRefs consumes (tier 2,
// LatestRelease.Components[component].SHA/Version on a fresh parse).
func TestFinalizer_ComponentPublish_MarkerRoundTripsToChangelogRead(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
require.NoError(t, os.WriteFile(path, []byte(publishedComponentManifest), 0644))

fin, err := NewFinalizer(path, "prod", WithComponent("api"), WithClock(fixedClock()))
require.NoError(t, err)
fin.SetActor("octocat")
fin.SetPromotionResult(&PromotionResult{
Promotions: []EnvPromotion{{Environment: "prod", SourceEnv: "dev", SHA: "apirel2", Version: "api-v1.1.0"}},
ReleaseAction: "publish",
ReleaseData: &ReleaseData{SHA: "apirel2", RCVersion: "api-v1.1.0-rc.0", SemVersion: "api-v1.1.0"},
})
require.NoError(t, fin.Run())

reparsed, err := config.ParseManifestFile(path, config.DefaultManifestKey)
require.NoError(t, err)
require.NotNil(t, reparsed.LatestRelease, "latest_release must parse back")

marker := reparsed.LatestRelease.Components["api"]
require.NotNil(t, marker, "the component's recorded marker must resolve on the typed read")
require.Equal(t, "apirel2", marker.SHA, "changelog tier-2 read must see this publish's sha")
require.Equal(t, "api-v1.1.0", marker.Version, "changelog tier-2 read must see this publish's version")
require.Equal(t, "2026-07-08T12:00:00Z", marker.ReleasedOn)

// The sibling's typed marker is untouched.
sibling := reparsed.LatestRelease.Components["web"]
require.NotNil(t, sibling)
require.Equal(t, "webrel1", sibling.SHA)
require.Equal(t, "web-v2.0.0", sibling.Version)
}

// TestPromoter_ComponentSave_DoesNotTouchLatestRelease covers the simulate
// (non-dry-run promote) save: promotion never mutates latest_release, and the
// component form node-patches only addressed leaves, so the save must leave
// latest_release byte-verbatim. Before the fix it wrote the parsed shared
// record whole under the promoting component's leaf, synthesizing a phantom
// marker (carrying the sibling's marker nested inside it) for a component that
// has never released.
func TestPromoter_ComponentSave_DoesNotTouchLatestRelease(t *testing.T) {
const manifest = `ci:
config:
environments: [dev, prod]
components:
api:
path: services/api
tag_grammar:
prefix: api-v
web:
path: services/web
tag_grammar:
prefix: web-v
state:
components:
api:
dev:
sha: apidev1
version: api-v0.1.0-rc.0
latest_release:
components:
web:
version: web-v2.0.0
sha: webrel1
custom_marker: keep-me-verbatim
`
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
require.NoError(t, os.WriteFile(path, []byte(manifest), 0644))

promoter, err := NewPromoter(PromoterOptions{
ConfigPath: path,
DryRun: false,
Actor: "promoter",
Component: "api",
})
require.NoError(t, err)

result, err := promoter.Promote(ModeDefault, "")
require.NoError(t, err)
require.True(t, result.Success, "promote failed: %s", result.Error)

out, err := os.ReadFile(path)
require.NoError(t, err)
comps := latestComponentsNode(t, readManifestNode(t, out))

// api has never released: the save must not synthesize a marker for it.
_, phantom := comps["api"]
require.False(t, phantom,
"promotion save synthesized a phantom latest_release marker for a component that never released:\n%s", out)

// The sibling's recorded marker survives verbatim.
web, ok := comps["web"].(map[string]any)
require.True(t, ok, "web marker must survive an api promotion save")
require.Equal(t, "web-v2.0.0", web["version"])
require.Equal(t, "webrel1", web["sha"])
require.True(t, strings.Contains(string(out), "custom_marker: keep-me-verbatim"),
"web's unmodeled marker key must survive verbatim:\n%s", out)
}
9 changes: 6 additions & 3 deletions internal/promote/promote.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,9 +817,12 @@ func (p *Promoter) serializeState(current []byte, key string) ([]byte, error) {
for env, st := range p.cicdFile.State {
writes = append(writes, config.StateWrite{Component: p.component, Env: env, State: st})
}
if p.cicdFile.LatestRelease != nil {
writes = append(writes, config.StateWrite{Component: p.component, Latest: p.cicdFile.LatestRelease})
}
// No latest_release directive: promotion never mutates the release record,
// and the component form node-patches only addressed leaves, so omitting the
// directive preserves the component's recorded marker and every sibling
// verbatim. Re-emitting the parsed record here previously rewrote (or, for a
// component that had never released, synthesized) the component's marker
// from the shared top-level record on every save.
return config.WriteScopedState(current, key, writes...)
}

Expand Down