From b90dc61a6a0734b466e6e53f46856dd138bd99fd Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 06:14:59 -0400 Subject: [PATCH] feat(rollback): record per-component rollback state and namespace the rollback ref Rollback for a selected component records its state at state.components.. through the scoped serializer, preserving a sibling component's state across the CommitWithRetry re-apply on a 409. The rollback ref becomes rollback// (rollback/ byte-identical when no component). First-environment eligibility and the previous-version ring are judged against the component's own environment subset, read from its overlaid state rows, so a component is never blocked or targeted by a sibling's ladder. An empty component keeps every path flat and byte-identical. Refs #294. Signed-off-by: Joshua Temple --- internal/promote/rollback_ref.go | 13 + internal/rollback/command.go | 5 + internal/rollback/command_subcommands.go | 12 +- internal/rollback/component_test.go | 370 +++++++++++++++++++++++ internal/rollback/rollback.go | 232 ++++++++++++-- 5 files changed, 614 insertions(+), 18 deletions(-) create mode 100644 internal/rollback/component_test.go diff --git a/internal/promote/rollback_ref.go b/internal/promote/rollback_ref.go index 723af94f..9c652019 100644 --- a/internal/promote/rollback_ref.go +++ b/internal/promote/rollback_ref.go @@ -10,6 +10,19 @@ import "strings" // hotfix integration branch, tags, or release drafts exist. const RollbackRefPrefix = "rollback/" +// RollbackRef returns the divergence ref recorded on an environment rolled back +// under component. The default (empty) component yields rollback/, +// byte-identical to the historical single-component form; a named component +// yields rollback// so each component's rollback divergence +// occupies a disjoint namespace, mirroring the env// integration +// branch namespacing a hotfix uses. +func RollbackRef(component, env string) string { + if component == "" { + return RollbackRefPrefix + env + } + return RollbackRefPrefix + component + "/" + env +} + // IsRollbackRef reports whether ref is a rollback-divergence ref (set by a // manual rollback) rather than a hotfix integration ref. The rejoin cleanup // uses this to skip integration-branch and hotfix-release deletion for an env diff --git a/internal/rollback/command.go b/internal/rollback/command.go index ec10149a..3ca5efea 100644 --- a/internal/rollback/command.go +++ b/internal/rollback/command.go @@ -18,6 +18,7 @@ func NewCommand() *cobra.Command { env string to string deployable string + component string actor string dryRun bool jsonOutput bool @@ -62,6 +63,7 @@ Examples: env: env, to: to, deployable: deployable, + component: component, actor: actor, dryRun: dryRun, jsonOutput: jsonOutput, @@ -74,6 +76,7 @@ Examples: cmd.Flags().StringVar(&env, "env", "", "Target environment to roll back (required)") cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (optional; defaults to the previous version)") cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") + cmd.Flags().StringVar(&component, "component", "", "Scope the rollback to a declared component (reads and records state.components..)") cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the rollback (default: $GITHUB_ACTOR)") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Resolve and print the plan without modifying the manifest") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output the resolved plan as JSON") @@ -96,6 +99,7 @@ type runOptions struct { env string to string deployable string + component string actor string dryRun bool jsonOutput bool @@ -106,6 +110,7 @@ func run(opts runOptions) error { ConfigPath: opts.configPath, ManifestKey: opts.manifestKey, Actor: opts.actor, + Component: opts.component, }) if err != nil { return err diff --git a/internal/rollback/command_subcommands.go b/internal/rollback/command_subcommands.go index 5df0d796..cd1b55b7 100644 --- a/internal/rollback/command_subcommands.go +++ b/internal/rollback/command_subcommands.go @@ -26,6 +26,7 @@ func newPreflightCommand() *cobra.Command { env string to string deployable string + component string ghaOutput bool jsonOutput bool ) @@ -46,6 +47,7 @@ target_version, and can_proceed to $GITHUB_OUTPUT. It writes no manifest state.` env: env, to: to, deployable: deployable, + component: component, ghaOutput: ghaOutput, jsonOutput: jsonOutput, }) @@ -57,6 +59,7 @@ target_version, and can_proceed to $GITHUB_OUTPUT. It writes no manifest state.` cmd.Flags().StringVar(&env, "env", "", "Target environment to roll back (required)") cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (optional; defaults to the previous version)") cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") + cmd.Flags().StringVar(&component, "component", "", "Scope the rollback to a declared component") cmd.Flags().BoolVar(&ghaOutput, "gha-output", false, "Write resolved target to $GITHUB_OUTPUT") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Print the resolved plan as JSON") @@ -71,6 +74,7 @@ type preflightOptions struct { env string to string deployable string + component string ghaOutput bool jsonOutput bool } @@ -79,6 +83,7 @@ func runPreflight(opts preflightOptions) error { rb, err := New(Options{ ConfigPath: opts.configPath, ManifestKey: opts.manifestKey, + Component: opts.component, }) if err != nil { if opts.ghaOutput { @@ -136,6 +141,7 @@ func newFinalizeCommand() *cobra.Command { env string to string deployable string + component string actor string commitPush bool ) @@ -156,6 +162,7 @@ promotion guards treat it as off-trunk until a promotion rejoins it), and, with env: env, to: to, deployable: deployable, + component: component, actor: actor, commitPush: commitPush, }) @@ -167,6 +174,7 @@ promotion guards treat it as off-trunk until a promotion rejoins it), and, with cmd.Flags().StringVar(&env, "env", "", "Target environment to roll back (required)") cmd.Flags().StringVar(&to, "to", "", "Prior version or SHA to re-promote (optional; defaults to the previous version)") cmd.Flags().StringVar(&deployable, "deployable", "", "Scope the rollback to a single deployable") + cmd.Flags().StringVar(&component, "component", "", "Scope the rollback to a declared component") cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the rollback (default: $GITHUB_ACTOR)") cmd.Flags().BoolVar(&commitPush, "commit-push", false, "Commit and push the updated manifest to the trunk branch") @@ -181,6 +189,7 @@ type finalizeOptions struct { env string to string deployable string + component string actor string commitPush bool } @@ -190,6 +199,7 @@ func runFinalize(opts finalizeOptions) error { ConfigPath: opts.configPath, ManifestKey: opts.manifestKey, Actor: opts.actor, + Component: opts.component, }) if err != nil { return err @@ -214,7 +224,7 @@ func runFinalize(opts finalizeOptions) error { } if opts.commitPush { - if err := commitAndPush(rb.ConfigPath(), plan.Environment, rb.GitIdentity()); err != nil { + if err := rb.CommitAndPush(); err != nil { return fmt.Errorf("failed to commit and push: %w", err) } fmt.Printf("State updated and committed for %s\n", plan.Environment) diff --git a/internal/rollback/component_test.go b/internal/rollback/component_test.go new file mode 100644 index 00000000..c0c6e79c --- /dev/null +++ b/internal/rollback/component_test.go @@ -0,0 +1,370 @@ +package rollback + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/statewrite" +) + +// twoComponentManifest is a component-scoped manifest whose recorded state lives +// entirely under state.components... "checkout" carries a +// deploy-history ring the rollback under test resolves against; the sibling +// "billing" component is never addressed, so every assertion proves it survives +// verbatim. +const twoComponentManifest = `ci: + config: + trunk_branch: main + environments: [dev, prod] + components: + checkout: + path: services/checkout + tag_prefix: checkout- + billing: + path: services/billing + tag_prefix: billing- + state: + components: + checkout: + prod: + sha: checkoutcur + version: v1.4.0 + committed_by: someone + previous: + - sha: checkoutprev + version: v1.3.0 + billing: + prod: + sha: billingcur + version: v2.0.0 + committed_by: billing-bot + unmodeled_key: keep-me + previous: + - sha: billingprev + version: v1.9.0 +` + +// parseManifestTree parses raw manifest bytes into a generic tree so a test can +// assert on the exact serialized shape, including keys the typed model ignores. +func parseManifestTree(t *testing.T, data []byte) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, yaml.Unmarshal(data, &m)) + return m +} + +// componentEnvLeaf digs out ci.state.components.. from a parsed +// manifest, failing the test when any level is missing. +func componentEnvLeaf(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 +} + +// TestApply_Component_WritesScopedNodeAndKeepsSibling proves the local-disk write +// path records the rolled-back env under state.components.., namespaces +// the divergence ref as rollback//, and leaves the unaddressed +// sibling component byte-intact. A regression to the flat WriteManifestState path +// would drop state.components.billing here. +func TestApply_Component_WritesScopedNodeAndKeepsSibling(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(twoComponentManifest), 0644)) + + rb, err := New(Options{ConfigPath: path, Actor: "oncall", Component: "checkout"}) + require.NoError(t, err) + + // Default target resolves the N-1 entry from checkout's own ring. + plan, err := rb.Plan("prod", "", "") + require.NoError(t, err) + require.Equal(t, "checkoutprev", plan.Target.SHA) + require.Equal(t, "previous-ring", plan.Target.Source) + + require.NoError(t, rb.Apply(plan)) + + out, err := os.ReadFile(path) + require.NoError(t, err) + m := parseManifestTree(t, out) + + // checkout's prod state was written under its own subtree with the rollback ref + // namespaced to the component. + got := componentEnvLeaf(t, m, "checkout", "prod") + require.Equal(t, "checkoutprev", got["sha"]) + require.Equal(t, "v1.3.0", got["version"]) + require.Equal(t, "oncall", got["committed_by"]) + require.Equal(t, "rollback/checkout/prod", got["ref"]) + + // The sibling billing component is preserved verbatim, including the key the + // binary does not model. + sib := componentEnvLeaf(t, m, "billing", "prod") + require.Equal(t, "billingcur", sib["sha"]) + require.Equal(t, "v2.0.0", sib["version"]) + require.Equal(t, "billing-bot", sib["committed_by"]) + require.Equal(t, "keep-me", sib["unmodeled_key"]) + + // No flat state. leaked alongside the component form. + ci := m["ci"].(map[string]any) + state := ci["state"].(map[string]any) + _, hasFlatProd := state["prod"] + require.False(t, hasFlatProd, "component rollback must not write a flat state.prod node") +} + +// conflictOnceClient is a fake Contents client that models a concurrent sibling +// rollback: it serves trunk bytes, and on the first PutContent it (a) rewrites +// trunk to carry a racing finalizer's freshly committed sibling subtree that the +// writing binary never modeled, 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) } + +// TestConcurrentFinalize_SiblingSurvivesThrough409Reapply drives the real rollback +// API write path (writeStateViaAPI -> statewrite.CommitWithRetry -> the +// Rollbacker's own Mutate closure), not the serializer in isolation. Component +// "checkout" rolls back (checkout, prod) while a concurrent finalizer for +// "billing" wins the race and commits state.components.billing.prod between +// checkout's read and its PUT. checkout's re-applied write must land +// state.components.checkout.prod AND preserve the billing subtree the losing +// binary never modeled. +func TestConcurrentFinalize_SiblingSurvivesThrough409Reapply(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY", "owner/repo") + t.Setenv("GITHUB_SERVER_URL", "https://github.com") + t.Setenv("GITHUB_REF", "refs/heads/main") + + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(twoComponentManifest), 0644)) + + // The winner's committed trunk: billing gains a NEW dev node plus an unmodeled + // key on prod, so the test proves survival of an unmodeled sibling subtree. + winnerTrunk := `ci: + config: + trunk_branch: main + environments: [dev, prod] + components: + checkout: + path: services/checkout + tag_prefix: checkout- + billing: + path: services/billing + tag_prefix: billing- + state: + components: + checkout: + prod: + sha: checkoutcur + version: v1.4.0 + previous: + - sha: checkoutprev + version: v1.3.0 + billing: + dev: + sha: billingdevsha + version: v2.1.0 + prod: + sha: billingprodsha + version: v2.0.0 + committed_by: billing-bot + unmodeled_key: keep-me +` + client := &conflictOnceClient{ + trunk: []byte(twoComponentManifest), + sha: "sha-initial", + injectYAML: winnerTrunk, + } + + rb, err := New(Options{ConfigPath: path, Actor: "checkout-bot", Component: "checkout"}) + require.NoError(t, err) + rb.contentsClient = client + + plan, err := rb.Plan("prod", "", "") + require.NoError(t, err) + require.NoError(t, rb.Apply(plan)) + + require.NoError(t, rb.writeStateViaAPI("chore: update state after rollback of prod [skip ci]")) + require.Equal(t, 2, client.puts, "expected exactly one 409 retry") + + final := parseManifestTree(t, client.trunk) + + // checkout's own leaf landed the rolled-back SHA. + checkout := componentEnvLeaf(t, final, "checkout", "prod") + require.Equal(t, "checkoutprev", checkout["sha"]) + require.Equal(t, "v1.3.0", checkout["version"]) + require.Equal(t, "checkout-bot", checkout["committed_by"]) + require.Equal(t, "rollback/checkout/prod", checkout["ref"]) + + // The winner's billing subtree survived verbatim, including the sibling env and + // the unmodeled key. + billProd := componentEnvLeaf(t, final, "billing", "prod") + require.Equal(t, "billingprodsha", billProd["sha"]) + require.Equal(t, "billing-bot", billProd["committed_by"]) + require.Equal(t, "keep-me", billProd["unmodeled_key"]) + billDev := componentEnvLeaf(t, final, "billing", "dev") + require.Equal(t, "billingdevsha", billDev["sha"]) +} + +// TestApply_SingleComponent_RefUnchanged proves the empty-component path is +// byte-identical: the rollback records a flat state. node and the historical +// rollback/ ref, with no components subtree introduced. +func TestApply_SingleComponent_RefUnchanged(t *testing.T) { + const flat = `ci: + config: + environments: [dev, prod] + state: + prod: + sha: prodcur + version: v1.4.0 + previous: + - sha: prodprev + version: v1.3.0 +` + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(flat), 0644)) + + rb, err := New(Options{ConfigPath: path, Actor: "oncall"}) + require.NoError(t, err) + plan, err := rb.Plan("prod", "", "") + require.NoError(t, err) + require.NoError(t, rb.Apply(plan)) + + out, err := os.ReadFile(path) + require.NoError(t, err) + m := parseManifestTree(t, out) + + ci := m["ci"].(map[string]any) + state := ci["state"].(map[string]any) + prod, ok := state["prod"].(map[string]any) + require.True(t, ok, "flat state.prod present") + require.Equal(t, "prodprev", prod["sha"]) + require.Equal(t, "rollback/prod", prod["ref"]) + _, hasComponents := state["components"] + require.False(t, hasComponents, "single-component rollback must not introduce a components subtree") +} + +// TestFirstEnv_ScopedToComponentSubset proves first-environment eligibility is +// judged against the selected component's own environment subset, not the global +// ladder. checkout's ladder starts at staging; the global ladder starts at dev. +// Rolling back checkout's staging trips the first-env guard, while the same env is +// not first globally. +func TestFirstEnv_ScopedToComponentSubset(t *testing.T) { + const manifest = `ci: + config: + environments: [dev, staging, prod] + components: + checkout: + path: services/checkout + tag_prefix: checkout- + environments: [staging, prod] + state: + components: + checkout: + prod: + sha: checkoutcur + version: v1.4.0 +` + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(manifest), 0644)) + + // Component scope: staging is checkout's first env, so the guard fires. + rbComp, err := New(Options{ConfigPath: path, Actor: "oncall", Component: "checkout"}) + require.NoError(t, err) + _, err = rbComp.Plan("staging", "", "") + require.Error(t, err) + require.Contains(t, err.Error(), "first environment") + + // Same env under the global (single-component) ladder is NOT first (dev is), so + // staging is judged on its merits and the error, if any, is not the first-env + // guard. + rbSingle, err := New(Options{ConfigPath: path, Actor: "oncall"}) + require.NoError(t, err) + _, err = rbSingle.Plan("staging", "", "") + if err != nil { + require.NotContains(t, err.Error(), "first environment") + } + + // The global first env (dev) still trips the guard on the single-component path, + // proving that path is unchanged. + _, err = rbSingle.Plan("dev", "", "") + require.Error(t, err) + require.Contains(t, err.Error(), "first environment") +} + +// TestPreviousRing_ScopedToComponent proves the deploy-history ring the default +// target resolves against is the selected component's own ring, not a sibling's or +// a flat one. checkout's ring N-1 is checkoutprev; billing's is billingprev. +func TestPreviousRing_ScopedToComponent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(twoComponentManifest), 0644)) + + rb, err := New(Options{ConfigPath: path, Actor: "oncall", Component: "checkout"}) + require.NoError(t, err) + + plan, err := rb.Plan("prod", "", "") + require.NoError(t, err) + require.Equal(t, "checkoutprev", plan.Target.SHA, "must resolve checkout's own ring, not a sibling's") + require.Equal(t, "previous-ring", plan.Target.Source) + require.NotEqual(t, "billingprev", plan.Target.SHA) +} + +// TestComponentReapply_Idempotent proves re-running the Rollbacker's Mutate closure +// against bytes that already carry its target leaf yields identical bytes: the +// node-patch is idempotent, so a retry never churns or duplicates the env node. +func TestComponentReapply_Idempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(twoComponentManifest), 0644)) + + rb, err := New(Options{ConfigPath: path, Actor: "oncall", Component: "checkout"}) + require.NoError(t, err) + plan, err := rb.Plan("prod", "", "") + require.NoError(t, err) + require.NoError(t, rb.Apply(plan)) + + mutate := rb.stateMutation("ci") + first, err := mutate([]byte(twoComponentManifest)) + require.NoError(t, err) + second, err := mutate(first) + require.NoError(t, err) + require.Equal(t, string(first), string(second), "re-applied component write must be a no-op") + require.True(t, strings.Contains(string(first), "rollback/checkout/prod")) +} diff --git a/internal/rollback/rollback.go b/internal/rollback/rollback.go index d0436360..cad48850 100644 --- a/internal/rollback/rollback.go +++ b/internal/rollback/rollback.go @@ -12,6 +12,7 @@ package rollback import ( "fmt" "os" + "os/exec" "strings" "time" @@ -58,6 +59,13 @@ type Options struct { ManifestKey string // Actor is recorded as committed_by / deployed_by on the re-promotion. Actor string + // Component, when non-empty, names the declared component this rollback is + // scoped to. An empty value selects the single-component path, byte-identical + // to today. When set, the rollback reads and records state under + // state.components.., resolves the deploy-history ring and + // first-environment eligibility against the component's own environment + // subset, and namespaces the divergence ref as rollback//. + Component string // HistoryReader resolves prior states from manifest git history. When nil, // a git-backed reader rooted at the manifest is used. HistoryReader HistoryReader @@ -81,6 +89,28 @@ type Rollbacker struct { actor string cicdFile *config.CICDFile history HistoryReader + + // component names the declared component this rollback is scoped to, or "" for + // the single-component path. When set, state is read and recorded under + // state.components.. via config.WriteScopedState, whose + // node-patch preserves every sibling component verbatim under the + // concurrent-finalize retry loop. + component string + // environments is the effective environment ladder eligibility and first-env + // checks are judged against: the component's own subset when a component is + // selected, otherwise the global ladder. It is empty when no config is parsed, + // leaving the guards inert. + environments []string + // appliedEnv records the environment the most recent Apply mutated, so the + // component-scoped state write and the trunk commit message address the right + // leaf. It is set by Apply before any serialization. + appliedEnv string + // contentsClient overrides the GitHub Contents client used by the + // component-scoped API write path. It is nil in production (the default gh-CLI + // client is constructed on demand) and set only by tests to drive the + // optimistic-lock retry against a fake that simulates a concurrent finalizer's + // 409. + contentsClient statewrite.ContentsClient } // New constructs a Rollbacker, loading and parsing the manifest. @@ -110,12 +140,54 @@ func New(opts Options) (*Rollbacker, error) { history = newGitHistoryReader(configPath, key) } + // Resolve the effective environment ladder the eligibility and first-env guards + // judge against. For a component it is the component's own subset (its override + // or the inherited default); for the single-component path it is the global + // ladder, so the guards behave byte-identically to before. + var environments []string + if cicdFile.Config != nil { + environments = cicdFile.Config.Environments + if opts.Component != "" { + resolved, err := cicdFile.Config.ResolveComponent(opts.Component) + if err != nil { + return nil, fmt.Errorf("resolving component %q: %w", opts.Component, err) + } + environments = resolved.Config.Environments + } + } + + // Overlay the component's recorded per-env rows, read from + // state.components.., into the flat working state map so every + // State[env] lookup (target resolution, the deploy-history ring, current state) + // transparently sees that component's seed. It is a no-op for the empty + // component, keeping the single-component path byte-identical. This mirrors the + // promote/hotfix component-state overlay: the read counterpart to the + // component-scoped WriteScopedState writes. + if opts.Component != "" { + raw, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("reading manifest for component state: %w", err) + } + compState, err := config.ReadComponentState(raw, key, opts.Component) + if err != nil { + return nil, fmt.Errorf("reading component %q state: %w", opts.Component, err) + } + if cicdFile.State == nil { + cicdFile.State = make(map[string]*config.EnvState) + } + for env, st := range compState { + cicdFile.State[env] = st + } + } + return &Rollbacker{ - configPath: configPath, - manifestKey: key, - actor: actor, - cicdFile: cicdFile, - history: history, + configPath: configPath, + manifestKey: key, + actor: actor, + cicdFile: cicdFile, + history: history, + component: opts.Component, + environments: environments, }, nil } @@ -401,6 +473,10 @@ func (r *Rollbacker) Apply(plan *Plan) error { return nil } + // Record the env this Apply mutates so the component-scoped state write and the + // trunk commit message address the right leaf. + r.appliedEnv = plan.Environment + timestamp := time.Now().UTC().Format(time.RFC3339) if r.cicdFile.State == nil { @@ -457,8 +533,10 @@ func (r *Rollbacker) Apply(plan *Plan) error { // this from a hotfix divergence (no integration branch, tags, or drafts), // so the rejoin cleanup can skip the hotfix-specific teardown. No patches // are recorded: a rollback re-points at a prior SHA, it does not stack - // commits on a base. - env.Ref = promote.RollbackRefPrefix + plan.Environment + // commits on a base. The ref is namespaced to the component when one is + // selected (rollback//), mirroring the hotfix env-branch + // namespacing, and is byte-identical (rollback/) otherwise. + env.Ref = promote.RollbackRef(r.component, plan.Environment) env.BaseSHA = prevSHA } @@ -481,7 +559,7 @@ func (r *Rollbacker) writeConfig() error { if err != nil { return fmt.Errorf("failed to read manifest: %w", err) } - data, err := config.WriteManifestState(current, key, r.cicdFile.State, r.cicdFile.LatestRelease) + data, err := r.serializeState(current, key) if err != nil { return fmt.Errorf("failed to marshal manifest: %w", err) } @@ -491,6 +569,120 @@ func (r *Rollbacker) writeConfig() error { return nil } +// serializeState rewrites current with this rollback's owned state. In the +// single-component form (component == "") it reconciles the whole flat state node +// via WriteManifestState, byte-identical to the historical behavior. In the +// component-scoped form it node-patches only state.components.. +// through WriteScopedState, so a sibling component present in current survives +// verbatim. +func (r *Rollbacker) serializeState(current []byte, key string) ([]byte, error) { + if r.component != "" { + return config.WriteScopedState(current, key, r.ownedStateWrites()...) + } + return config.WriteManifestState(current, key, r.cicdFile.State, r.cicdFile.LatestRelease) +} + +// ownedStateWrites builds the component-scoped write this rollback owns from its +// already-mutated in-memory state: a single directive addressing +// state.components... It is re-appliable, so +// CommitWithRetry invokes it again against re-fetched trunk bytes on a 409 and +// deterministically re-derives the same owned leaf, leaving a concurrent sibling +// component's subtree untouched. It returns nil when no env has been applied or +// its state is unexpectedly absent, so an empty write never becomes an accidental +// node delete. +func (r *Rollbacker) ownedStateWrites() []config.StateWrite { + if r.appliedEnv == "" { + return nil + } + st := r.cicdFile.State[r.appliedEnv] + if st == nil { + return nil + } + return []config.StateWrite{{Component: r.component, Env: r.appliedEnv, State: st}} +} + +// stateMutation returns the re-appliable CommitWithRetry closure that node-patches +// only state.components.. onto whatever trunk bytes the +// retry loop fetches. It never deserializes or rebuilds a sibling component, so on +// a 409 the loser re-reads the winner's committed sibling subtree and re-applies +// only its own leaf, leaving the sibling verbatim, including keys this binary does +// not model. It is used only on the component-scoped API write path. +func (r *Rollbacker) stateMutation(key string) statewrite.Mutate { + return func(current []byte) ([]byte, error) { + data, err := config.WriteScopedState(current, key, r.ownedStateWrites()...) + if err != nil { + return nil, fmt.Errorf("marshaling merged manifest: %w", err) + } + return data, nil + } +} + +// resolvedManifestKey returns the manifest key state writes address, honoring an +// explicit config override and falling back to the configured default. +func (r *Rollbacker) resolvedManifestKey() string { + key := r.manifestKey + if key == "" { + key = config.DefaultManifestKey + } + if r.cicdFile.Config != nil && r.cicdFile.Config.ManifestKey != "" { + key = r.cicdFile.Config.ManifestKey + } + return key +} + +// CommitAndPush persists the post-rollback manifest back to the trunk branch. The +// single-component path is unchanged: it commits the on-disk file (already written +// by Apply) exactly as before. The component-scoped path goes through the shared +// optimistic-lock retry so two components rolling back concurrently merge rather +// than clobber each other on the file blob SHA: on real GitHub it re-applies its +// own component leaf over re-fetched trunk bytes via the Contents API, and in the +// act/gitea environment it commits the scoped on-disk file with plain git. +func (r *Rollbacker) CommitAndPush() error { + if r.component == "" { + return commitAndPush(r.configPath, r.appliedEnv, r.GitIdentity()) + } + + status, err := exec.Command("git", "status", "--porcelain", r.configPath).Output() + if err != nil { + return fmt.Errorf("git status failed: %w", err) + } + if len(status) == 0 { + return nil // No changes + } + + message := fmt.Sprintf("chore: update state after rollback of %s [skip ci]", r.appliedEnv) + if isRealGitHub() { + return r.writeStateViaAPI(message) + } + return commitAndPushGit(r.configPath, message, r.GitIdentity()) +} + +// writeStateViaAPI writes the manifest to the trunk branch through the GitHub +// Contents REST API using the shared optimistic-lock retry loop, node-patching +// only this rollback's component leaf so a concurrent sibling component's write is +// preserved rather than clobbered. It is the component-scoped counterpart to the +// single-component free writeStateViaAPI; the Contents client is injectable so the +// 409 re-apply can be exercised without a live API. +func (r *Rollbacker) writeStateViaAPI(message string) error { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("GITHUB_REPOSITORY is not set; cannot write state via API") + } + client := r.contentsClient + if client == nil { + client = statewrite.NewGHClient() + } + return statewrite.CommitWithRetry(statewrite.Options{ + Client: client, + Repo: repo, + Path: r.configPath, + Ref: trunkBranchFromEnv(), + Message: message, + Author: r.GitIdentity(), + Mutate: r.stateMutation(r.resolvedManifestKey()), + }) +} + // firstEnvErr returns a guard error when env is the first (build target) // environment and nil otherwise. The first environment tracks trunk and is // never promoted into, so its deploy-history ring is structurally always empty: @@ -499,27 +691,33 @@ func (r *Rollbacker) writeConfig() error { // environment is a revert merge to the trunk branch, not a rollback. The guard // is inert when no parsed config is available to identify the first environment, // so a state-only manifest still resolves through the normal path. +// +// Eligibility is judged against the effective ladder: the selected component's own +// environment subset when a component is set, otherwise the global ladder. A +// component whose ladder starts at a different environment than the global build +// target is thus judged on its own first environment, not the global one. The +// guard is inert when no ladder is available (a state-only manifest), so it +// resolves through the normal path. func (r *Rollbacker) firstEnvErr(env string) error { - if r.cicdFile == nil || r.cicdFile.Config == nil { + if len(r.environments) == 0 { return nil } - if r.cicdFile.Config.IsFirstEnvironment(env) { + if r.environments[0] == env { return fmt.Errorf("environment %q is the first environment; it tracks trunk and is never promoted into, so it has no rollback history. Revert it with a merge to the trunk branch instead of a rollback", env) } return nil } -// knownEnvironment reports whether env is declared in config.environments or -// has recorded state. +// knownEnvironment reports whether env is in the effective environment ladder (the +// selected component's subset, or the global ladder for the single-component path) +// or has recorded state. func (r *Rollbacker) knownEnvironment(env string) bool { if r.cicdFile.State[env] != nil { return true } - if r.cicdFile.Config != nil { - for _, e := range r.cicdFile.Config.Environments { - if e == env { - return true - } + for _, e := range r.environments { + if e == env { + return true } } return false