From c4288d0bd0d654be578a0d33d2c5e34da8a06243 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 04:59:59 -0400 Subject: [PATCH] feat(hotfix): record per-component finalize state and scope hotfix tags A hotfix on a selected component now records its state at state.components.. through the scoped serializer instead of rewriting the whole flat state node, so a concurrent hotfix on another component keeps its recorded state intact across the CommitWithRetry re-apply on a 409. The finalizer overlays the component's persisted rows, re-applies the hotfix, and node-patches only its own subtree; an empty component keeps the flat path, byte-identical. Hotfix tags resolve through the component's tag grammar (strict prefix), so a hotfix tag is created and looked up in the component's namespace and never cross-matches a sibling. The finalizer records env// for a component and env/ otherwise. Refs #293. Signed-off-by: Joshua Temple --- internal/hotfix/command.go | 4 +- internal/hotfix/finalize.go | 197 +++++++++++--- internal/hotfix/finalize_component_test.go | 302 +++++++++++++++++++++ internal/hotfix/lifecycle.go | 20 ++ 4 files changed, 491 insertions(+), 32 deletions(-) create mode 100644 internal/hotfix/finalize_component_test.go diff --git a/internal/hotfix/command.go b/internal/hotfix/command.go index dedc7f1e..7516a645 100644 --- a/internal/hotfix/command.go +++ b/internal/hotfix/command.go @@ -46,6 +46,7 @@ func newFinalizeCommand() *cobra.Command { fixSHA string baseSHA string actor string + component string dryRun bool deployFlags []string buildFlags []string @@ -67,7 +68,7 @@ After the resolution PR merges and the build and deploy succeed, this command: The verb is idempotent on identical inputs: a rerun after the state already records the merge SHA is a no-op.`, RunE: func(cmd *cobra.Command, args []string) error { - opts := []FinalizeOption{WithFinalizeDryRun(dryRun)} + opts := []FinalizeOption{WithFinalizeDryRun(dryRun), WithComponent(component)} finalizer, err := NewFinalizer(FinalizerOptions{ ConfigPath: configPath, @@ -109,6 +110,7 @@ records the merge SHA is a no-op.`, cmd.Flags().StringVar(&fixSHA, "fix-sha", "", "Trunk commit(s) the hotfix carries; comma-delimited for a multi-commit set (required)") cmd.Flags().StringVar(&baseSHA, "base-sha", "", "Trunk anchor the integration branch diverged from (required)") cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the state (default: $GITHUB_ACTOR)") + cmd.Flags().StringVar(&component, "component", "", "Declared component to scope the hotfix to (default: single-component manifest)") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and compute without writing state, tags, or releases") cmd.Flags().StringArrayVar(&deployFlags, "deploy-result", nil, "Deploy result as name=result (repeatable)") cmd.Flags().StringArrayVar(&buildFlags, "build-result", nil, "Build result as name=result (repeatable)") diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index 6b55b5a1..8602b1f8 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -218,6 +218,23 @@ type Finalizer struct { actor string dryRun bool + // component, when non-empty, names the declared component this hotfix is + // scoped to. It is set only via WithComponent by a per-component generated + // hotfix workflow. An empty value selects the single-component path, whose + // state write and env-branch name are byte-identical to the historical + // behavior. A non-empty value records state under + // state.components.. via WriteScopedState (preserving every + // sibling component subtree under the concurrent-finalize retry loop), + // resolves the hotfix version and tag in the component's own tag namespace, + // and names the integration branch env//. + component string + + // trunkRaw holds the raw manifest bytes read from the trunk branch. It is the + // write basis for the component-scoped local write so a node-patch preserves + // every sibling component's trunk-recorded subtree rather than overwriting it + // with the lagging env-branch checkout. + trunkRaw []byte + deployResults map[string]string buildResults map[string]string @@ -249,6 +266,14 @@ func WithFinalizeDryRun(dryRun bool) FinalizeOption { return func(f *Finalizer) { f.dryRun = dryRun } } +// WithComponent scopes the finalize to a declared component. It records state +// under state.components.., resolves the hotfix version and tag in the +// component's tag namespace, and names the integration branch env//. +// An empty name (the default) keeps the single-component behavior byte-identical. +func WithComponent(name string) FinalizeOption { + return func(f *Finalizer) { f.component = name } +} + // WithReleaseManager injects the release operations. When unset, finalize builds // a *release.Manager from GITHUB_REPOSITORY and the release token at run time. func WithReleaseManager(m releaseManager) FinalizeOption { @@ -398,11 +423,12 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA string, fixSHAs []string, baseS // WRITE basis below so mutating only the target env preserves every other // env's recorded trunk state. Writing the lagging env-branch manifest to // trunk would clobber the non-target envs. - trunkCICD, err := f.readTrunkManifest(trunk) + trunkRaw, trunkCICD, err := f.readTrunkManifest(trunk) if err != nil { return err } f.cicd = trunkCICD + f.trunkRaw = trunkRaw cfg = f.cicd.Config if cfg == nil { return fmt.Errorf("trunk manifest has no config block") @@ -411,12 +437,19 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA string, fixSHAs []string, baseS if f.cicd.State == nil { f.cicd.State = make(map[string]*config.EnvState) } + // For a component-scoped hotfix the prior env state lives under + // state.components.., not the flat state. node. Overlay it + // into the flat map so the prior-state read, idempotency gate, and Previous + // ring below see the component's recorded row. + if err := overlayComponentState(f.cicd, trunkRaw, f.manifestKey, f.component); err != nil { + return err + } prior := f.cicd.State[targetEnv] if prior == nil || prior.SHA == "" { return fmt.Errorf("environment %q has no recorded state SHA", targetEnv) } - branch := envBranch(targetEnv) + branch := f.envBranch(targetEnv) // Idempotency gate: if state already records the merge SHA, finalize already // ran for these inputs. Re-running must not double-apply. @@ -458,7 +491,7 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA string, fixSHAs []string, baseS return err } - if err := f.writeConfig(); err != nil { + if err := f.writeConfig(targetEnv); err != nil { return err } @@ -466,27 +499,10 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA string, fixSHAs []string, baseS pusher := f.pusher if !f.pusherInjected && isRealGitHub() { - capturedVersion := hotfixVersion - capturedTimestamp := timestamp - capturedBaseSHA := baseSHA - capturedFixSHAs := append([]string(nil), fixSHAs...) - capturedTarget := targetEnv - capturedMerge := mergeSHA - key := f.manifestKey - pusher = apiStatePusher{author: gitIdentity(f.cicd.Config), mutate: func(current []byte) ([]byte, error) { - fresh, err := config.ParseManifestBytes(current, key) - if err != nil { - return nil, fmt.Errorf("parsing current manifest: %w", err) - } - if err := f.applyHotfixState(fresh, capturedTarget, capturedMerge, capturedVersion, capturedBaseSHA, capturedTimestamp, capturedFixSHAs); err != nil { - return nil, err - } - data, err := config.WriteManifestState(current, key, fresh.State, fresh.LatestRelease) - if err != nil { - return nil, fmt.Errorf("marshaling merged manifest: %w", err) - } - return data, nil - }} + pusher = apiStatePusher{ + author: gitIdentity(f.cicd.Config), + mutate: f.hotfixMutation(targetEnv, mergeSHA, hotfixVersion, baseSHA, timestamp, fixSHAs), + } } if err := pusher.CommitAndPush(f.configPath, trunk, message); err != nil { return fmt.Errorf("committing hotfix state: %w", err) @@ -523,7 +539,7 @@ func (f *Finalizer) applyHotfixState(cicd *config.CICDFile, targetEnv, mergeSHA, prior.BaseSHA = baseSHA } prior.Patches = append(prior.Patches, fixSHAs...) - prior.Ref = envBranch(targetEnv) + prior.Ref = f.envBranch(targetEnv) prior.SHA = mergeSHA prior.Version = hotfixVersion prior.CommittedAt = timestamp @@ -532,22 +548,118 @@ func (f *Finalizer) applyHotfixState(cicd *config.CICDFile, targetEnv, mergeSHA, return nil } +// hotfixMutation returns the re-appliable CommitWithRetry closure that merges +// this hotfix's owned env state onto whatever trunk bytes the loop fetches. +// +// Single-component form (component == ""): re-parse the fetched bytes, re-apply +// the hotfix state mutation for the target env, and reconcile the whole flat +// state node via WriteManifestState. Sibling envs survive because the re-read +// carries them into the typed map. This is byte-identical to the historical +// closure. +// +// Component-scoped form (component != ""): overlay the component's persisted env +// rows from the fetched bytes, re-apply the hotfix mutation, then node-patch only +// state.components.. via WriteScopedState. It never +// deserializes or rebuilds a sibling component subtree, so on a 409 the loser +// re-reads the winner's committed sibling rows and re-applies only its own leaf, +// leaving every sibling component verbatim, including keys this binary does not +// model. +func (f *Finalizer) hotfixMutation(targetEnv, mergeSHA, hotfixVersion, baseSHA, timestamp string, fixSHAs []string) statewrite.Mutate { + key := f.manifestKey + component := f.component + capturedFixSHAs := append([]string(nil), fixSHAs...) + return func(current []byte) ([]byte, error) { + fresh, err := config.ParseManifestBytes(current, key) + if err != nil { + return nil, fmt.Errorf("parsing current manifest: %w", err) + } + if err := overlayComponentState(fresh, current, key, component); err != nil { + return nil, err + } + if err := f.applyHotfixState(fresh, targetEnv, mergeSHA, hotfixVersion, baseSHA, timestamp, capturedFixSHAs); err != nil { + return nil, err + } + if component != "" { + data, err := config.WriteScopedState(current, key, f.hotfixStateWrites(fresh, targetEnv)...) + if err != nil { + return nil, fmt.Errorf("marshaling merged manifest: %w", err) + } + return data, nil + } + data, err := config.WriteManifestState(current, key, fresh.State, fresh.LatestRelease) + if err != nil { + return nil, fmt.Errorf("marshaling merged manifest: %w", err) + } + return data, nil + } +} + +// hotfixStateWrites builds the component-scoped write this finalize owns from its +// already-mutated env state: one directive addressing +// state.components... It is re-appliable, so CommitWithRetry +// re-derives the same owned leaf on a 409 and never rebuilds a sibling subtree. A +// nil target leaf (an unexpected miss) contributes no directive rather than an +// accidental node delete. +func (f *Finalizer) hotfixStateWrites(cicd *config.CICDFile, targetEnv string) []config.StateWrite { + st := cicd.State[targetEnv] + if st == nil { + return nil + } + return []config.StateWrite{{ + Component: f.component, + Env: targetEnv, + State: st, + }} +} + // readTrunkManifest fetches the manifest as it exists on the trunk branch and // returns the parsed manifest. It is read from trunk because promote finalize // writes env state only to trunk; the env branch the hotfix merged into lags // trunk and can record stale or absent state. The returned manifest is both the // source of the prior env state and the WRITE basis, so mutating only the target // env preserves every other env's recorded trunk state. -func (f *Finalizer) readTrunkManifest(trunk string) (*config.CICDFile, error) { +func (f *Finalizer) readTrunkManifest(trunk string) ([]byte, *config.CICDFile, error) { data, err := f.trunkReader.ReadManifest(f.configPath, trunk) if err != nil { - return nil, fmt.Errorf("reading trunk state: %w", err) + return nil, nil, fmt.Errorf("reading trunk state: %w", err) } cicd, err := config.ParseManifestBytes(data, f.manifestKey) if err != nil { - return nil, fmt.Errorf("parsing trunk manifest: %w", err) + return nil, nil, fmt.Errorf("parsing trunk manifest: %w", err) + } + return data, cicd, nil +} + +// overlayComponentState overlays the named component's recorded per-env rows, +// read from state.components.. in raw, into cicd's flat state map +// so every State[env] lookup in Finalize transparently sees that component's +// seed. It is a no-op when component is empty, keeping the single-component path +// byte-identical. It mirrors promote's component-state overlay: the read +// counterpart to the component-scoped WriteScopedState writes. +func overlayComponentState(cicd *config.CICDFile, raw []byte, manifestKey, component string) error { + if component == "" { + return nil + } + compState, err := config.ReadComponentState(raw, manifestKey, component) + if err != nil { + return fmt.Errorf("reading component %q state: %w", component, err) + } + if cicd.State == nil { + cicd.State = make(map[string]*config.EnvState) } - return cicd, nil + for env, st := range compState { + cicd.State[env] = st + } + return nil +} + +// envBranch returns the integration branch name for env under this finalize's +// component. The default (empty) component yields env/, byte-identical to +// the historical single-component name; a named component yields +// env// so each component's integration branches occupy a +// disjoint namespace. +func (f *Finalizer) envBranch(env string) string { + return EnvBranchName(f.component, env) } // allocateVersion returns the next free hotfix version over priorVersion. @@ -561,7 +673,10 @@ func (f *Finalizer) allocateVersion(priorVersion string) (string, error) { if priorVersion == "" { return "", fmt.Errorf("target environment has no recorded version; cannot allocate a hotfix version") } - spec := resolveTagGrammar(f.cicd) + spec, err := resolveFinalizeSpec(f.cicd, f.component) + if err != nil { + return "", err + } v, err := version.ParseWithGrammar(spec, priorVersion) if err != nil { return "", fmt.Errorf("parsing target version %q: %w", priorVersion, err) @@ -720,7 +835,27 @@ func (f *Finalizer) isPrereleaseEnv(cfg *config.TrunkConfig, env string) bool { // mutable state subtree so any configuration this binary does not model is // preserved rather than dropped. It matches the layout promote's finalize // produces. -func (f *Finalizer) writeConfig() error { +// +// Single-component form (component == ""): reconcile the whole flat state node +// against the on-disk manifest via WriteManifestState, byte-identical to the +// historical behavior. +// +// Component-scoped form (component != ""): node-patch only +// state.components.. onto the trunk manifest bytes via +// WriteScopedState. The trunk bytes are the write basis (not the lagging +// env-branch checkout) so every sibling component's trunk-recorded subtree is +// preserved verbatim when the git push lands this file on trunk. +func (f *Finalizer) writeConfig(targetEnv string) error { + if f.component != "" { + data, err := config.WriteScopedState(f.trunkRaw, f.manifestKey, f.hotfixStateWrites(f.cicd, targetEnv)...) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + if err := os.WriteFile(f.configPath, data, 0o600); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + return nil + } current, err := os.ReadFile(f.configPath) if err != nil { return fmt.Errorf("reading manifest: %w", err) diff --git a/internal/hotfix/finalize_component_test.go b/internal/hotfix/finalize_component_test.go new file mode 100644 index 00000000..aeab180a --- /dev/null +++ b/internal/hotfix/finalize_component_test.go @@ -0,0 +1,302 @@ +package hotfix + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/statewrite" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// hotfixComponentManifest is a two-component manifest whose recorded state lives +// entirely under state.components... It carries a sibling component +// ("web") the hotfix under test never addresses, so every assertion proves that +// sibling survives verbatim. +const hotfixComponentManifest = `ci: + config: + trunk_branch: main + environments: [dev, prod] + components: + api: + path: api + tag_prefix: api- + web: + path: web + tag_prefix: web- + state: + components: + api: + prod: + sha: apiprodsha + version: api-1.4.0-rc.2 + web: + prod: + sha: webprodsha + version: web-1.4.0-rc.2 + committed_by: web-bot + unmodeled_key: keep-me +` + +// readManifestNode parses raw manifest bytes into a generic tree so tests can +// assert on the exact serialized shape, including keys the typed model ignores. +func readManifestNode(t *testing.T, data []byte) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, yaml.Unmarshal(data, &m)) + return m +} + +// componentEnvNode digs out ci.state.components.. from a parsed +// manifest, failing the test when any level is missing. +func componentEnvNode(t *testing.T, m map[string]any, comp, env string) map[string]any { + t.Helper() + ci, ok := m["ci"].(map[string]any) + require.True(t, ok, "ci block present") + state, ok := ci["state"].(map[string]any) + require.True(t, ok, "ci.state present") + comps, ok := state["components"].(map[string]any) + require.True(t, ok, "ci.state.components present") + c, ok := comps[comp].(map[string]any) + require.True(t, ok, "component %s present", comp) + leaf, ok := c[env].(map[string]any) + require.True(t, ok, "component %s env %s present", comp, env) + return leaf +} + +// conflictOnceClient models a concurrent sibling finalize through the real +// optimistic-lock retry loop: it serves trunk bytes, and on the first PutContent +// it (a) rewrites trunk to carry a racing finalizer's freshly committed sibling +// subtree, exactly as the winner would have, and (b) returns a 409 so +// CommitWithRetry re-fetches and re-applies. The second PutContent succeeds. +type conflictOnceClient struct { + trunk []byte + sha string + puts int + injectYAML string +} + +func (c *conflictOnceClient) GetContent(_, _, _ string) ([]byte, string, error) { + return c.trunk, c.sha, nil +} + +func (c *conflictOnceClient) PutContent(_, _, _, _, _ string, content []byte, _ statewrite.Identity) error { + c.puts++ + if c.puts == 1 { + c.trunk = []byte(c.injectYAML) + c.sha = "sha-after-winner" + return &statewrite.ConflictError{Err: errString("does not match 409")} + } + c.trunk = content + c.sha = "sha-final" + return nil +} + +type errString string + +func (e errString) Error() string { return string(e) } + +// TestHotfixFinalize_ConcurrentFinalize_SiblingSurvivesThrough409Reapply is the +// load-bearing concurrency test. It drives the REAL hotfix finalize write path +// (hotfixMutation -> statewrite.CommitWithRetry) rather than a serializer in +// isolation. Component "api" finalizes a hotfix on prod while a concurrent +// finalize for "web" wins the race and commits state.components.web between api's +// read and its PUT. api's re-applied write must land +// state.components.api.prod AND preserve the web subtree the losing binary never +// modeled, including a sibling env and an unmodeled key. A regression to a +// whole-state-node rebuild (WriteManifestState) would delete web on the re-apply. +func TestHotfixFinalize_ConcurrentFinalize_SiblingSurvivesThrough409Reapply(t *testing.T) { + const mergeSHA = "apimergesha" + timestamp := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) + + // The winner's committed trunk: web gains a NEW dev node plus an unmodeled key + // on prod, and api keeps its original prod row so the loser re-reads its own + // prior state. + const winnerTrunk = `ci: + config: + trunk_branch: main + environments: [dev, prod] + components: + api: + path: api + tag_prefix: api- + web: + path: web + tag_prefix: web- + state: + components: + api: + prod: + sha: apiprodsha + version: api-1.4.0-rc.2 + web: + dev: + sha: webdevsha + version: web-2.1.0 + prod: + sha: webprodsha + version: web-1.4.0-rc.2 + committed_by: web-bot + unmodeled_key: keep-me +` + + f := &Finalizer{ + manifestKey: "ci", + actor: "api-bot", + component: "api", + deployResults: map[string]string{}, + buildResults: map[string]string{}, + } + mutate := f.hotfixMutation("prod", mergeSHA, "api-1.4.0-rc.2.hotfix.1", "apibase", timestamp, []string{"fixsha"}) + + client := &conflictOnceClient{ + trunk: []byte(hotfixComponentManifest), + sha: "sha-initial", + injectYAML: winnerTrunk, + } + + err := statewrite.CommitWithRetry(statewrite.Options{ + Client: client, + Repo: "owner/repo", + Path: "manifest.yaml", + Ref: "main", + Message: "chore: record hotfix", + Mutate: mutate, + Sleep: func(time.Duration) {}, + }) + require.NoError(t, err) + require.Equal(t, 2, client.puts, "expected exactly one 409 retry") + + final := readManifestNode(t, client.trunk) + + // api's own leaf landed under its component subtree with the component env + // branch name. + api := componentEnvNode(t, final, "api", "prod") + require.Equal(t, mergeSHA, api["sha"]) + require.Equal(t, "api-1.4.0-rc.2.hotfix.1", api["version"]) + require.Equal(t, "env/api/prod", api["ref"], "component hotfix records env//") + require.Equal(t, "apibase", api["base_sha"]) + require.Equal(t, "api-bot", api["committed_by"]) + + // The winner's web subtree survived verbatim, including the sibling env and + // the unmodeled key the loser's typed model never carried. + webProd := componentEnvNode(t, final, "web", "prod") + require.Equal(t, "webprodsha", webProd["sha"]) + require.Equal(t, "web-1.4.0-rc.2", webProd["version"]) + require.Equal(t, "web-bot", webProd["committed_by"]) + require.Equal(t, "keep-me", webProd["unmodeled_key"], "unmodeled sibling key must survive the re-apply") + webDev := componentEnvNode(t, final, "web", "dev") + require.Equal(t, "webdevsha", webDev["sha"]) + + // No flat state.prod node leaked alongside the component form. + ci := final["ci"].(map[string]any) + state := ci["state"].(map[string]any) + _, hasFlatProd := state["prod"] + require.False(t, hasFlatProd, "component hotfix must not write a flat state.prod node") +} + +// TestHotfixFinalize_ComponentReapply_Idempotent proves re-running the mutation +// against bytes that already carry the hotfix leaf yields identical bytes: the +// node-patch is idempotent, so a retry never churns or double-applies. +func TestHotfixFinalize_ComponentReapply_Idempotent(t *testing.T) { + timestamp := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) + f := &Finalizer{ + manifestKey: "ci", + actor: "api-bot", + component: "api", + deployResults: map[string]string{}, + buildResults: map[string]string{}, + } + mutate := f.hotfixMutation("prod", "apimergesha", "api-1.4.0-rc.2.hotfix.1", "apibase", timestamp, []string{"fixsha"}) + + first, err := mutate([]byte(hotfixComponentManifest)) + require.NoError(t, err) + second, err := mutate(first) + require.NoError(t, err) + require.Equal(t, string(first), string(second), "re-applied component hotfix write must be a no-op") +} + +// TestHotfixFinalize_SingleComponentMutation_ByteIdentical proves the empty +// component takes the exact original WriteManifestState path, so a +// single-component hotfix mutation is byte-identical to the direct reference. +func TestHotfixFinalize_SingleComponentMutation_ByteIdentical(t *testing.T) { + const flat = `ci: + config: + environments: [dev, prod] + state: + dev: + sha: devsha + version: v1.0.0 + prod: + sha: prodsha + version: v1.4.0 +` + timestamp := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) + + f := &Finalizer{ + manifestKey: "ci", + actor: "tester", + deployResults: map[string]string{}, + buildResults: map[string]string{}, + } + got, err := f.hotfixMutation("prod", "mergesha", "v1.4.1", "basesha", timestamp, []string{"fixsha"})([]byte(flat)) + require.NoError(t, err) + + // Reference: mirror the exact in-memory mutation, then serialize the + // historical way via WriteManifestState. + ref := &Finalizer{ + manifestKey: "ci", + actor: "tester", + deployResults: map[string]string{}, + buildResults: map[string]string{}, + } + fresh, err := config.ParseManifestBytes([]byte(flat), "ci") + require.NoError(t, err) + require.NoError(t, ref.applyHotfixState(fresh, "prod", "mergesha", "v1.4.1", "basesha", timestamp, []string{"fixsha"})) + want, err := config.WriteManifestState([]byte(flat), "ci", fresh.State, fresh.LatestRelease) + require.NoError(t, err) + + require.Equal(t, string(want), string(got), "single-component hotfix mutation must be byte-identical to WriteManifestState") +} + +// TestHotfixFinalize_EnvBranchName_ScopedToComponent proves applyHotfixState +// records the component-scoped integration branch env//, and the +// default (empty) component keeps the byte-identical env/ form. +func TestHotfixFinalize_EnvBranchName_ScopedToComponent(t *testing.T) { + timestamp := time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) + + comp := &Finalizer{actor: "t", manifestKey: "ci", component: "api"} + compCICD := &config.CICDFile{State: map[string]*config.EnvState{"prod": {SHA: "old", Version: "api-1.0.0"}}} + require.NoError(t, comp.applyHotfixState(compCICD, "prod", "m", "api-1.0.1", "b", timestamp, []string{"x"})) + require.Equal(t, "env/api/prod", compCICD.State["prod"].Ref) + + flat := &Finalizer{actor: "t", manifestKey: "ci"} + flatCICD := &config.CICDFile{State: map[string]*config.EnvState{"prod": {SHA: "old", Version: "v1.0.0"}}} + require.NoError(t, flat.applyHotfixState(flatCICD, "prod", "m", "v1.0.1", "b", timestamp, []string{"x"})) + require.Equal(t, "env/prod", flatCICD.State["prod"].Ref) +} + +// TestHotfixFinalize_AllocateVersion_ScopedToComponentNamespace proves version +// allocation resolves the hotfix version in the component's own tag namespace: a +// sibling component's hotfix tags never block or advance this component's +// allocation, and the taken api hotfix.1 correctly advances to api hotfix.2. +func TestHotfixFinalize_AllocateVersion_ScopedToComponentNamespace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(hotfixComponentManifest), 0o600)) + + tags := []string{ + "api-1.4.0-rc.2.hotfix.1", // taken in api's namespace + "web-1.4.0-rc.2.hotfix.1", // sibling namespace, must be ignored + "web-1.4.0-rc.2.hotfix.2", // sibling namespace, must be ignored + } + f := newFinalizer(t, path, WithComponent("api"), WithTagLister(stubTagLister{tags: tags})) + + got, err := f.allocateVersion("api-1.4.0-rc.2") + require.NoError(t, err) + require.Equal(t, "api-1.4.0-rc.2.hotfix.2", got, + "api's next hotfix skips the taken api hotfix.1 and ignores web's hotfix tags") +} diff --git a/internal/hotfix/lifecycle.go b/internal/hotfix/lifecycle.go index e7e3ad11..2f32af67 100644 --- a/internal/hotfix/lifecycle.go +++ b/internal/hotfix/lifecycle.go @@ -19,6 +19,26 @@ func resolveTagGrammar(f *config.CICDFile) taggrammar.Spec { return f.Config.ResolveTagGrammar() } +// resolveFinalizeSpec returns the tag grammar a hotfix finalize reads and emits +// its versions and tags under. For the default (empty) component it is the +// manifest's permissive grammar, byte-identical to the single-component +// behavior. For a named component it is that component's resolved grammar with a +// strict prefix, so a hotfix version and tag land in and are looked up from the +// component's own namespace and never cross-match a sibling component's tags. +func resolveFinalizeSpec(f *config.CICDFile, component string) (taggrammar.Spec, error) { + if component == "" { + return resolveTagGrammar(f), nil + } + if f == nil || f.Config == nil { + return taggrammar.Spec{}, fmt.Errorf("component %q requested but manifest has no config block", component) + } + resolved, err := f.Config.ResolveComponent(component) + if err != nil { + return taggrammar.Spec{}, fmt.Errorf("resolving component %q tag grammar: %w", component, err) + } + return resolved.TagGrammarSpec(), nil +} + // EnvBranchPrefix is the prefix of the per-environment integration branches a // hotfix creates (for example env/test). A branch carrying this prefix exists // only while its environment is diverged; once the env rejoins trunk the branch