diff --git a/internal/promote/command.go b/internal/promote/command.go index 06c2362f..6ee8f521 100644 --- a/internal/promote/command.go +++ b/internal/promote/command.go @@ -7,11 +7,12 @@ import ( // Shared flags across subcommands var ( - configPath string - dryRun bool - jsonOutput bool - ghaOutput bool - actor string + configPath string + dryRun bool + jsonOutput bool + ghaOutput bool + actor string + componentName string ) // NewCommand creates the promote parent command with subcommands. @@ -41,6 +42,7 @@ The preflight and finalize subcommands are designed for GitHub Actions workflows cmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output result as JSON") cmd.PersistentFlags().BoolVar(&ghaOutput, "gha-output", false, "Write to $GITHUB_OUTPUT") cmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor performing action (default: from GITHUB_ACTOR)") + cmd.PersistentFlags().StringVar(&componentName, "component", "", "Declared component to scope promotion state to (default: single-component)") // Add subcommands cmd.AddCommand(newPreflightCommand()) diff --git a/internal/promote/command_finalize.go b/internal/promote/command_finalize.go index 0b23577b..fe962258 100644 --- a/internal/promote/command_finalize.go +++ b/internal/promote/command_finalize.go @@ -67,6 +67,9 @@ func runFinalize() error { // hotfix tags, and drafts. Without GitHub context the no-op default is kept, // so non-diverged promotions and unit-test runs are unaffected. var finalizeOpts []FinalizeOption + if componentName != "" { + finalizeOpts = append(finalizeOpts, WithComponent(componentName)) + } if commitPush { if cleaner := newFinalizeCleaner(); cleaner != nil { finalizeOpts = append(finalizeOpts, WithLifecycleCleaner(cleaner)) diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index e436c7fa..1c3c49e4 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -33,6 +33,20 @@ type Finalizer struct { actor string overrideSHA string // non-empty when an auto-committing callback advanced HEAD + // component, when non-empty, names the declared component this finalization is + // scoped to. It is set only via WithComponent by a per-component generated + // promote workflow; an empty value selects the single-component path, + // byte-identical to today. When set, promoted state is serialized under + // state.components.. (and latest_release.components.) + // through config.WriteScopedState, whose node-patch preserves every sibling + // component verbatim under the concurrent-finalize retry loop. + component string + // contentsClient overrides the GitHub Contents client used by writeStateViaAPI. + // It is nil in production (the default gh-CLI client is constructed on demand) + // and set only by tests via withContentsClient to drive the optimistic-lock + // retry against a fake that simulates a concurrent finalizer's 409. + contentsClient statewrite.ContentsClient + // cleaner performs the divergence-end side effects (delete env branch, hotfix // tags, drafts) when a promotion rejoins a diverged env to trunk. The default // is a no-op so non-diverged promotions are unaffected. @@ -339,7 +353,7 @@ func (f *Finalizer) WriteConfig() error { if err != nil { return fmt.Errorf("failed to read config: %w", err) } - data, err := config.WriteManifestState(current, key, f.cicdFile.State, f.cicdFile.LatestRelease) + data, err := f.serializeState(current, key) if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } @@ -349,6 +363,60 @@ func (f *Finalizer) WriteConfig() error { return nil } +// serializeState rewrites current with this finalizer'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.. +// (and latest_release.components.) through WriteScopedState, so a +// sibling component present in current survives verbatim. +func (f *Finalizer) serializeState(current []byte, key string) ([]byte, error) { + if f.component != "" { + return config.WriteScopedState(current, key, f.componentStateWrites()...) + } + return config.WriteManifestState(current, key, f.cicdFile.State, f.cicdFile.LatestRelease) +} + +// componentStateWrites builds the component-scoped writes this finalizer owns +// from its already-mutated in-memory state: one state directive per promoted env +// addressing state.components.., plus a latest_release directive +// addressing latest_release.components. on a publish. It is +// re-appliable: CommitWithRetry invokes it again against re-fetched trunk bytes +// on a 409, and each call deterministically re-derives the same owned leaves, so +// a concurrent sibling component's subtree is never rebuilt or dropped. +// +// The global "release"/"prerelease" markers that updateState maintains in the +// flat in-memory map are intentionally NOT emitted here: whether those markers +// become per-component or stay global is a release-pipeline decision, so this +// scopes the component write to the env ladder plus +// latest_release.components.. +func (f *Finalizer) componentStateWrites() []config.StateWrite { + if f.promotionResult == nil { + return nil + } + writes := make([]config.StateWrite, 0, len(f.promotionResult.Promotions)) + for _, promo := range f.promotionResult.Promotions { + // A promoted env is always populated in State by updateState. Guard against + // a nil state so an unexpected miss never becomes an accidental node delete + // (a nil State on a StateWrite means delete). + st := f.cicdFile.State[promo.Environment] + if st == nil { + continue + } + writes = append(writes, config.StateWrite{ + Component: f.component, + Env: promo.Environment, + State: st, + }) + } + if f.promotionResult.ReleaseAction == "publish" { + writes = append(writes, config.StateWrite{ + Component: f.component, + Latest: f.cicdFile.LatestRelease, + }) + } + return writes +} + // CommitAndPush persists the manifest changes back to the trunk branch. // // On real GitHub the write goes through the Contents REST API (via the gh CLI): @@ -417,26 +485,54 @@ func (f *Finalizer) writeStateViaAPI(message string) error { if f.cicdFile.Config != nil && f.cicdFile.Config.ManifestKey != "" { key = f.cicdFile.Config.ManifestKey } + client := f.contentsClient + if client == nil { + client = statewrite.NewGHClient() + } return statewrite.CommitWithRetry(statewrite.Options{ - Client: statewrite.NewGHClient(), + Client: client, Repo: repo, Path: f.configPath, Ref: branch, Message: message, Author: gitIdentity(f.cicdFile.Config), - Mutate: func(current []byte) ([]byte, error) { - into, err := config.ParseManifestBytes(current, key) - if err != nil { - return nil, fmt.Errorf("parsing current manifest: %w", err) - } - f.overlayOwnedState(into) - data, err := config.WriteManifestState(current, key, into.State, into.LatestRelease) + Mutate: f.stateMutation(key), + }) +} + +// stateMutation returns the re-appliable CommitWithRetry closure that merges this +// finalizer's owned state onto whatever trunk bytes the retry loop fetches. +// +// Single-component form: re-parse the fetched bytes into a full manifest, overlay +// only the owned envs (overlayOwnedState), and reconcile the whole flat state node +// via WriteManifestState. Sibling envs survive because the re-read carries them +// into the typed map. +// +// Component-scoped form: node-patch only state.components.. (and +// latest_release.components.) via WriteScopedState. 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. +func (f *Finalizer) stateMutation(key string) statewrite.Mutate { + return func(current []byte) ([]byte, error) { + if f.component != "" { + data, err := config.WriteScopedState(current, key, f.componentStateWrites()...) if err != nil { return nil, fmt.Errorf("marshaling merged manifest: %w", err) } return data, nil - }, - }) + } + into, err := config.ParseManifestBytes(current, key) + if err != nil { + return nil, fmt.Errorf("parsing current manifest: %w", err) + } + f.overlayOwnedState(into) + data, err := config.WriteManifestState(current, key, into.State, into.LatestRelease) + if err != nil { + return nil, fmt.Errorf("marshaling merged manifest: %w", err) + } + return data, nil + } } // overlayOwnedState copies the state this finalizer owns from its in-memory, diff --git a/internal/promote/finalize_component_test.go b/internal/promote/finalize_component_test.go new file mode 100644 index 00000000..3b5a6d0a --- /dev/null +++ b/internal/promote/finalize_component_test.go @@ -0,0 +1,317 @@ +package promote + +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" +) + +// componentManifest is a two-component manifest whose state lives entirely under +// state.components.., the component-scoped form. It carries a sibling +// component ("billing") the writes below never address, so every test asserts it +// survives verbatim. +const componentManifest = `ci: + config: + environments: [dev, prod] + state: + components: + billing: + prod: + sha: billsha + version: v2.0.0 + committed_by: someone +` + +// fixedClock returns a deterministic clock so emitted audit timestamps are exact. +func fixedClock() func() time.Time { + return func() time.Time { return time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) } +} + +// promotionForComponent builds a single-env promotion result the finalizer turns +// into one scoped state write. +func promotionForComponent(env, sha, version string) *PromotionResult { + return &PromotionResult{ + Promotions: []EnvPromotion{{Environment: env, SHA: sha, Version: version}}, + } +} + +// 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 +} + +// componentEnv digs out ci.state.components.. from a parsed manifest, +// failing the test when any level is missing. +func componentEnv(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 +} + +// TestFinalizer_WriteConfig_Component_WritesScopedNodeAndKeepsSibling proves the +// local-disk WriteConfig path records the promoted env under +// state.components.. and leaves an unaddressed sibling component +// byte-intact. A regression to WriteManifestState (whole-state-node rebuild) +// would drop state.components.billing here. +func TestFinalizer_WriteConfig_Component_WritesScopedNodeAndKeepsSibling(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(componentManifest), 0644)) + + fin, err := NewFinalizer(path, "prod", WithComponent("checkout"), WithClock(fixedClock())) + require.NoError(t, err) + fin.SetActor("promoter") + fin.SetPromotionResult(promotionForComponent("prod", "checkoutsha", "v1.4.0")) + + require.NoError(t, fin.Run()) + + out, err := os.ReadFile(path) + require.NoError(t, err) + m := readManifestNode(t, out) + + // checkout's prod state was written under its own subtree. + got := componentEnv(t, m, "checkout", "prod") + require.Equal(t, "checkoutsha", got["sha"]) + require.Equal(t, "v1.4.0", got["version"]) + require.Equal(t, "promoter", got["committed_by"]) + + // The sibling billing component is preserved verbatim. + sib := componentEnv(t, m, "billing", "prod") + require.Equal(t, "billsha", sib["sha"]) + require.Equal(t, "v2.0.0", sib["version"]) + require.Equal(t, "someone", sib["committed_by"]) + + // 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 finalize must not write a flat state.prod node") +} + +// TestFinalizer_WriteConfig_SingleComponent_ByteIdentical proves an empty +// component takes the exact original WriteManifestState path, so a +// single-component manifest round-trips byte-for-byte identical to the +// historical single-component behavior. +func TestFinalizer_WriteConfig_SingleComponent_ByteIdentical(t *testing.T) { + const flat = `ci: + config: + environments: [dev, prod] + state: + dev: + sha: devsha + version: v1.0.0 + prod: {} +` + dir := t.TempDir() + + // Path A: the new finalizer with no component set. + pathA := filepath.Join(dir, "a.yaml") + require.NoError(t, os.WriteFile(pathA, []byte(flat), 0644)) + finA, err := NewFinalizer(pathA, "prod", WithClock(fixedClock())) + require.NoError(t, err) + finA.SetActor("promoter") + finA.SetPromotionResult(promotionForComponent("prod", "prodsha", "v1.0.0")) + require.NoError(t, finA.Run()) + gotA, err := os.ReadFile(pathA) + require.NoError(t, err) + + // Path B: the direct WriteManifestState reference, mirroring the exact + // in-memory mutation the finalizer performs, then serialized the historical + // way. Both must be byte-identical. + pathB := filepath.Join(dir, "b.yaml") + require.NoError(t, os.WriteFile(pathB, []byte(flat), 0644)) + finB, err := NewFinalizer(pathB, "prod", WithClock(fixedClock())) + require.NoError(t, err) + finB.SetActor("promoter") + finB.SetPromotionResult(promotionForComponent("prod", "prodsha", "v1.0.0")) + finB.updateState() + ref, err := config.WriteManifestState([]byte(flat), config.DefaultManifestKey, finB.cicdFile.State, finB.cicdFile.LatestRelease) + require.NoError(t, err) + + require.Equal(t, string(ref), string(gotA), "single-component finalize must be byte-identical to WriteManifestState") +} + +// conflictOnceClient is a fake Contents client that models a concurrent sibling +// finalize: it serves trunk bytes, and on the first PutContent it (a) injects a +// brand-new sibling-component env node that the writing binary never modeled, +// exactly as a racing finalizer would have committed, 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 // sibling subtree spliced in on the conflict +} + +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 { + // A concurrent finalizer won the race: rewrite trunk to carry its freshly + // committed sibling subtree, then reject this PUT with a 409 so the loser + // re-reads and re-applies on top. + 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) } + +// TestFinalizer_ConcurrentFinalize_SiblingSurvivesThrough409Reapply is the +// keystone concurrency test. It drives the REAL finalize write path (writeStateViaAPI -> +// statewrite.CommitWithRetry -> the finalizer's own Mutate closure), not the +// serializer in isolation. Component "checkout" finalizes (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. A regression to a +// whole-state-node rebuild would delete billing on the re-apply. +func TestFinalizer_ConcurrentFinalize_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") + // The finalizer's own manifest carries only its config; the trunk bytes the + // client serves are the source of truth for the merge. + require.NoError(t, os.WriteFile(path, []byte(componentManifest), 0644)) + + // The winner's committed trunk: billing gains a NEW prod node (with a field + // the loser's typed model does not carry) plus a sibling env, so the test + // proves survival of an unmodeled sibling subtree, the #389 class at + // component depth. + winnerTrunk := `ci: + config: + environments: [dev, prod] + state: + components: + billing: + dev: + sha: billdevsha + version: v2.1.0 + prod: + sha: billprodsha + version: v2.0.0 + committed_by: billing-bot + unmodeled_key: keep-me +` + client := &conflictOnceClient{ + trunk: []byte(componentManifest), + sha: "sha-initial", + injectYAML: winnerTrunk, + } + + fin, err := NewFinalizer(path, "prod", + WithComponent("checkout"), + WithClock(fixedClock()), + withContentsClient(client), + ) + require.NoError(t, err) + fin.SetActor("checkout-bot") + fin.SetPromotionResult(promotionForComponent("prod", "checkoutprodsha", "v1.4.0")) + fin.updateState() + + require.NoError(t, fin.writeStateViaAPI("chore: update state")) + require.Equal(t, 2, client.puts, "expected exactly one 409 retry") + + final := readManifestNode(t, client.trunk) + + // checkout's own leaf landed. + checkout := componentEnv(t, final, "checkout", "prod") + require.Equal(t, "checkoutprodsha", checkout["sha"]) + require.Equal(t, "v1.4.0", checkout["version"]) + require.Equal(t, "checkout-bot", checkout["committed_by"]) + + // The winner's billing subtree survived verbatim, including the sibling env + // and the unmodeled key. + billProd := componentEnv(t, final, "billing", "prod") + require.Equal(t, "billprodsha", billProd["sha"]) + require.Equal(t, "v2.0.0", billProd["version"]) + require.Equal(t, "billing-bot", billProd["committed_by"]) + require.Equal(t, "keep-me", billProd["unmodeled_key"], "unmodeled sibling key must survive the re-apply") + billDev := componentEnv(t, final, "billing", "dev") + require.Equal(t, "billdevsha", billDev["sha"]) +} + +// TestFinalizer_ComponentReapply_Idempotent proves re-running the finalizer'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 component's env node. +func TestFinalizer_ComponentReapply_Idempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(componentManifest), 0644)) + + fin, err := NewFinalizer(path, "prod", WithComponent("checkout"), WithClock(fixedClock())) + require.NoError(t, err) + fin.SetActor("promoter") + fin.SetPromotionResult(promotionForComponent("prod", "checkoutsha", "v1.4.0")) + fin.updateState() + + mutate := fin.stateMutation(config.DefaultManifestKey) + first, err := mutate([]byte(componentManifest)) + 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") +} + +// TestPromoter_saveConfig_Component_WritesScopedNode proves the Promoter's +// non-dry-run saveConfig (the simulate path) honors its component scope: state is +// serialized under state.components.. and an unaddressed sibling +// component survives. An empty component keeps the flat form (covered elsewhere). +func TestPromoter_saveConfig_Component_WritesScopedNode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(componentManifest), 0644)) + + p, err := NewPromoter(PromoterOptions{ConfigPath: path, Actor: "sim", Component: "checkout"}) + require.NoError(t, err) + + // Model the in-memory state the promoter would carry for its component. + p.cicdFile.State = map[string]*config.EnvState{ + "prod": {SHA: "checkoutsha", Version: "v1.4.0", CommittedBy: "sim"}, + } + require.NoError(t, p.saveConfig()) + + out, err := os.ReadFile(path) + require.NoError(t, err) + m := readManifestNode(t, out) + + got := componentEnv(t, m, "checkout", "prod") + require.Equal(t, "checkoutsha", got["sha"]) + require.Equal(t, "v1.4.0", got["version"]) + + sib := componentEnv(t, m, "billing", "prod") + require.Equal(t, "billsha", sib["sha"]) + require.Equal(t, "v2.0.0", sib["version"]) +} diff --git a/internal/promote/promote.go b/internal/promote/promote.go index 29502582..dfaf4450 100644 --- a/internal/promote/promote.go +++ b/internal/promote/promote.go @@ -66,6 +66,10 @@ type Promoter struct { actor string force bool // For default mode: continue on failure ancestor AncestorFunc + // component, when non-empty, scopes a non-dry-run saveConfig (the simulate + // path) to state.components.. via config.WriteScopedState. An + // empty value keeps the flat single-component serialization, byte-identical. + component string } // PromoterOptions configures the Promoter @@ -74,6 +78,9 @@ type PromoterOptions struct { DryRun bool Actor string Force bool // For default mode: continue on failure + // Component scopes persisted state to the named declared component. Empty + // selects the single-component path, byte-identical to today. + Component string } // NewPromoter creates a new Promoter. Optional behavior (such as the @@ -99,6 +106,7 @@ func NewPromoter(opts PromoterOptions, options ...Option) (*Promoter, error) { actor: actor, force: opts.Force, ancestor: gc.ancestor, + component: opts.Component, }, nil } @@ -439,7 +447,7 @@ func (p *Promoter) noEnvironmentPromotion() (*PromotionResult, error) { SourceEnv: "prerelease", SHA: sourceState.SHA, Version: p.stripPreRelease(sourceState.Version), // Use semver for release - NeedsDeploy: false, // No deployment for library/CLI projects + NeedsDeploy: false, // No deployment for library/CLI projects } result := &PromotionResult{ @@ -708,13 +716,33 @@ func (p *Promoter) saveConfig() error { if err != nil { return fmt.Errorf("failed to read config: %w", err) } - data, err := config.WriteManifestState(current, key, p.cicdFile.State, p.cicdFile.LatestRelease) + data, err := p.serializeState(current, key) if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } return os.WriteFile(p.configPath, data, 0644) } +// serializeState rewrites current with the in-memory state. In the +// single-component form 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.. +// (and latest_release.components.) via WriteScopedState, preserving +// every sibling component verbatim. +func (p *Promoter) serializeState(current []byte, key string) ([]byte, error) { + if p.component == "" { + return config.WriteManifestState(current, key, p.cicdFile.State, p.cicdFile.LatestRelease) + } + writes := make([]config.StateWrite, 0, len(p.cicdFile.State)) + 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}) + } + return config.WriteScopedState(current, key, writes...) +} + // ToJSON returns the result as JSON func (r *PromotionResult) ToJSON() string { data, _ := json.MarshalIndent(r, "", " ") diff --git a/internal/promote/rejoin.go b/internal/promote/rejoin.go index c25c81fa..c64c72e4 100644 --- a/internal/promote/rejoin.go +++ b/internal/promote/rejoin.go @@ -8,6 +8,7 @@ import ( "github.com/stablekernel/cascade/internal/git" "github.com/stablekernel/cascade/internal/hotfix" "github.com/stablekernel/cascade/internal/release" + "github.com/stablekernel/cascade/internal/statewrite" "github.com/stablekernel/cascade/internal/taggrammar" ) @@ -80,6 +81,31 @@ func WithClock(now func() time.Time) FinalizeOption { } } +// WithComponent scopes finalization to the named declared component, so its +// promoted state is recorded under state.components.. (and, on a +// publish, latest_release.components.) via the scoped serializer rather +// than the flat single-component state. form. An empty name is a no-op, +// preserving the single-component path byte-identically. It is set by a +// per-component generated promote workflow passing --component. +func WithComponent(name string) FinalizeOption { + return func(f *Finalizer) { + f.component = name + } +} + +// withContentsClient injects the GitHub Contents client the API state-write path +// uses, so the optimistic-lock retry loop can be exercised against a fake that +// simulates a concurrent finalizer's 409. Production leaves it unset and the +// default gh-CLI client is used. It is unexported because it exists only for the +// concurrent-finalize tests, not the public API surface. +func withContentsClient(c statewrite.ContentsClient) FinalizeOption { + return func(f *Finalizer) { + if c != nil { + f.contentsClient = c + } + } +} + // rejoinEvent records that a diverged environment rejoined trunk during // finalization, carrying the data the cleaner needs to remove its branch, tags, // and drafts.