From dbc7851b61c5a4f73427d55655eb9d7c3485dfb5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 10:56:19 -0400 Subject: [PATCH 1/2] fix: read hotfix finalize prior state from trunk Hotfix finalize read the prior environment state from the checked-out manifest, which on the pull_request closed run is the env branch. Promote finalize writes env state only to trunk, so the env branch lags trunk and records a stale or absent state SHA, making finalize fail with 'environment has no recorded state SHA'. Finalize now reads the prior state from the trunk manifest, resolving trunk the same way the state write does. Reading goes through the Contents API on real GitHub and plain git under act, mirroring the existing write seam. Signed-off-by: Joshua Temple --- internal/config/parse.go | 18 +++- internal/hotfix/finalize.go | 120 ++++++++++++++++++++-- internal/hotfix/finalize_test.go | 167 ++++++++++++++++++++++++++++++- 3 files changed, 293 insertions(+), 12 deletions(-) diff --git a/internal/config/parse.go b/internal/config/parse.go index df202a5..e6f8971 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -24,6 +24,23 @@ func ParseManifestFile(path, key string) (*CICDFile, error) { return nil, fmt.Errorf("reading manifest file: %w", err) } + file, err := ParseManifestBytes(data, key) + if err != nil { + return nil, err + } + + if file.Config != nil { + file.Config.ManifestFile = path + } + return file, nil +} + +// ParseManifestBytes parses manifest bytes with config under a specific key, +// mirroring ParseManifestFile without reading from disk. The manifest must have +// the specified key (default: "ci") at the top level. Returns an error if the +// key is not found. This is the entry point for reading a manifest fetched from +// a branch ref (for example, trunk) rather than the working tree. +func ParseManifestBytes(data []byte, key string) (*CICDFile, error) { if key == "" { key = DefaultManifestKey } @@ -56,7 +73,6 @@ func ParseManifestFile(path, key string) (*CICDFile, error) { // Ensure all environments have state entries if file.Config != nil { - file.Config.ManifestFile = path file.Config.ManifestKey = key for _, env := range file.Config.Environments { if file.State[env] == nil { diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index 6474249..4a6a0ce 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -44,6 +44,57 @@ type gitTipReader interface { LocalBranchSHA(name string) (string, error) } +// trunkStateReader returns the raw manifest bytes as they exist on the trunk +// branch, so finalize can read prior env state from trunk rather than from the +// checked-out env branch. Promote finalize writes env state only to trunk, so +// the env branch the hotfix merged into lags trunk and can record a stale or +// absent state SHA for the target env; reading at trunk is the source of truth. +// The default implementation reads via the GitHub Contents API on real GitHub +// and plain git under act; tests inject a stub. +type trunkStateReader interface { + ReadManifest(path, trunk string) ([]byte, error) +} + +// gitOrAPITrunkReader reads the manifest at the trunk ref. On real GitHub it +// fetches the file through the Contents REST API at the trunk ref; under +// act/gitea it fetches the trunk branch and shows the blob at that ref. +type gitOrAPITrunkReader struct{} + +func (gitOrAPITrunkReader) ReadManifest(path, trunk string) ([]byte, error) { + if isRealGitHub() { + return readManifestViaAPI(path, trunk) + } + return readManifestViaGit(path, trunk) +} + +// readManifestViaAPI fetches the manifest at the trunk ref through the GitHub +// Contents REST API using the gh CLI, returning the raw file bytes. +func readManifestViaAPI(path, trunk string) ([]byte, error) { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return nil, fmt.Errorf("GITHUB_REPOSITORY is not set; cannot read trunk state via API") + } + apiPath := fmt.Sprintf("repos/%s/contents/%s?ref=%s", repo, path, trunk) + out, err := exec.Command("gh", "api", apiPath, "-H", "Accept: application/vnd.github.raw").Output() + if err != nil { + return nil, fmt.Errorf("reading trunk manifest via API at %s: %w", trunk, err) + } + return out, nil +} + +// readManifestViaGit fetches the trunk branch and returns the manifest blob at +// that ref. Used in the act/gitea e2e environment, where there is no GitHub API. +func readManifestViaGit(path, trunk string) ([]byte, error) { + if out, err := exec.Command("git", "fetch", "origin", trunk).CombinedOutput(); err != nil { + return nil, fmt.Errorf("git fetch origin %s failed: %s: %w", trunk, strings.TrimSpace(string(out)), err) + } + out, err := exec.Command("git", "show", "origin/"+trunk+":"+path).Output() + if err != nil { + return nil, fmt.Errorf("reading trunk manifest via git at origin/%s: %w", trunk, err) + } + return out, nil +} + // envTipReader resolves an env branch tip in a CI checkout. The finalize job // checks out trunk and fetches env/* into refs/remotes/origin/*, so the branch // is usually a remote-tracking ref rather than a local one. It resolves the @@ -171,10 +222,11 @@ type Finalizer struct { deployResults map[string]string buildResults map[string]string - releaseMgr releaseManager - tagLister tagLister - pusher statePusher - tipReader gitTipReader + releaseMgr releaseManager + tagLister tagLister + pusher statePusher + tipReader gitTipReader + trunkReader trunkStateReader } // FinalizerOptions carries the required inputs for NewFinalizer. @@ -222,6 +274,19 @@ func WithStatePusher(p statePusher) FinalizeOption { } } +// WithTrunkStateReader injects the reader that returns the manifest as it exists +// on the trunk branch. Finalize uses it to read prior env state from trunk, the +// source of truth, rather than from the lagging env branch the hotfix merged +// into. The default reads via the GitHub Contents API on real GitHub and plain +// git under act. +func WithTrunkStateReader(r trunkStateReader) FinalizeOption { + return func(f *Finalizer) { + if r != nil { + f.trunkReader = r + } + } +} + // NewFinalizer constructs a Finalizer over the manifest at opts.ConfigPath. func NewFinalizer(opts FinalizerOptions, options ...FinalizeOption) (*Finalizer, error) { key := opts.ManifestKey @@ -253,6 +318,7 @@ func NewFinalizer(opts FinalizerOptions, options ...FinalizeOption) (*Finalizer, tagLister: execTagLister{}, pusher: gitStatePusher{}, tipReader: envTipReader{}, + trunkReader: gitOrAPITrunkReader{}, } for _, o := range options { o(f) @@ -295,10 +361,34 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return fmt.Errorf("%q is not a configured environment", targetEnv) } - prior := f.cicd.State[targetEnv] + // Resolve trunk the same way the state write does: the configured trunk + // branch, defaulting to "main". + trunk := cfg.TrunkBranch + if trunk == "" { + trunk = "main" + } + + // Read prior env state from trunk, not from the checked-out env branch. + // Promote finalize writes env state only to trunk, so the env branch the + // hotfix merged into lags trunk and can record a stale or absent state SHA; + // trunk is the source of truth. The in-memory f.cicd remains the target of + // the WRITE below, preserving any env-branch-only edits to other fields. + trunkState, err := f.readTrunkState(trunk) + if err != nil { + return err + } + + prior := trunkState[targetEnv] if prior == nil || prior.SHA == "" { return fmt.Errorf("environment %q has no recorded state SHA", targetEnv) } + // Carry the trunk-resolved prior state into the in-memory manifest so the + // snapshot, divergence fields, and substates are applied over trunk's view + // and persisted by the write below. + if f.cicd.State == nil { + f.cicd.State = make(map[string]*config.EnvState) + } + f.cicd.State[targetEnv] = prior branch := envBranch(targetEnv) @@ -358,10 +448,6 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return err } - trunk := cfg.TrunkBranch - if trunk == "" { - trunk = "main" - } message := fmt.Sprintf("chore: record hotfix %s on %s [skip ci]", hotfixVersion, targetEnv) if err := f.pusher.CommitAndPush(f.configPath, trunk, message); err != nil { return fmt.Errorf("committing hotfix state: %w", err) @@ -375,6 +461,22 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return nil } +// readTrunkState fetches the manifest as it exists on the trunk branch and +// returns its env state map. Prior env state must be read from trunk because +// promote finalize writes env state only to trunk; the env branch the hotfix +// merged into lags trunk and can record a stale or absent state SHA. +func (f *Finalizer) readTrunkState(trunk string) (map[string]*config.EnvState, error) { + data, err := f.trunkReader.ReadManifest(f.configPath, trunk) + if err != nil { + return 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 cicd.State, nil +} + // allocateVersion returns the next free hotfix version over priorVersion. // // For an rc-based version (e.g. v1.4.0-rc.2) it allocates the next free dotted diff --git a/internal/hotfix/finalize_test.go b/internal/hotfix/finalize_test.go index e39cef5..e1199a7 100644 --- a/internal/hotfix/finalize_test.go +++ b/internal/hotfix/finalize_test.go @@ -48,6 +48,27 @@ func (r *recordingPusher) CommitAndPush(path, branch, message string) error { return nil } +// stubTrunkReader returns fixed manifest bytes as the trunk view, decoupling the +// prior-state read from the working tree. When bytes is set it is returned +// verbatim; otherwise the on-disk manifest at path is returned, modeling a trunk +// that matches the checked-out tree. +type stubTrunkReader struct { + bytes []byte + branch string // records the trunk branch finalize resolved + err error +} + +func (s *stubTrunkReader) ReadManifest(path, trunk string) ([]byte, error) { + s.branch = trunk + if s.err != nil { + return nil, s.err + } + if s.bytes != nil { + return s.bytes, nil + } + return os.ReadFile(path) //nolint:gosec // test path under t.TempDir +} + type envFixture struct { sha string version string @@ -92,10 +113,15 @@ func writeFinalizeManifest(t *testing.T, envs []string, states map[string]envFix return path } -// newFinalizer builds a Finalizer over the manifest with the supplied stubs. +// newFinalizer builds a Finalizer over the manifest with the supplied stubs. It +// injects a default trunk reader that returns the on-disk manifest bytes so +// tests that do not exercise the trunk-vs-tree distinction model a trunk that +// matches the checked-out tree. A caller-supplied WithTrunkStateReader appears +// later in opts and wins. func newFinalizer(t *testing.T, manifest string, opts ...FinalizeOption) *Finalizer { t.Helper() - f, err := NewFinalizer(FinalizerOptions{ConfigPath: manifest, ManifestKey: "ci", Actor: "tester"}, opts...) + all := append([]FinalizeOption{WithTrunkStateReader(&stubTrunkReader{})}, opts...) + f, err := NewFinalizer(FinalizerOptions{ConfigPath: manifest, ManifestKey: "ci", Actor: "tester"}, all...) if err != nil { t.Fatalf("NewFinalizer: %v", err) } @@ -465,6 +491,143 @@ func TestFinalize_StateWriteTargetsTrunkBranch(t *testing.T) { } } +// TestFinalize_ReadsPriorStateFromTrunk guards the trunk-state read: the +// checked-out (env-branch) manifest records no state SHA for the target env, +// because promote finalize writes env state only to trunk. Finalize must read +// prior state from the trunk manifest the reader returns and succeed, rather +// than erroring on the stale env-branch view. +func TestFinalize_ReadsPriorStateFromTrunk(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix on trunk") + runGit(t, "branch", "env/test", base) + runGit(t, "checkout", "env/test") + merge := commitFile(t, "c.txt", "fixed", "cherry-pick fix") + runGit(t, "checkout", "main") + + // On-disk (env-branch) manifest: test has an EMPTY state block (no sha). + onDisk := writeFinalizeManifest(t, []string{"dev", "test", "prod"}, map[string]envFixture{ + "dev": {sha: fix, version: "v1.4.0-rc.2"}, + "test": {}, // empty: no recorded SHA on the lagging env branch + "prod": {sha: base, version: "v1.4.0-rc.2"}, + }) + + // Trunk manifest: test records the real prior state SHA. + trunkBytes := []byte("ci:\n config:\n environments:\n" + + " - dev\n - test\n - prod\n" + + " state:\n" + + " dev:\n sha: " + fix + "\n version: v1.4.0-rc.2\n" + + " test:\n sha: " + base + "\n version: v1.4.0-rc.2\n" + + " prod:\n sha: " + base + "\n version: v1.4.0-rc.2\n") + + reader := &stubTrunkReader{bytes: trunkBytes} + rm := &stubReleaseManager{} + f := newFinalizer(t, onDisk, + WithReleaseManager(rm), + WithTagLister(stubTagLister{}), + WithStatePusher(&recordingPusher{}), + WithTrunkStateReader(reader), + ) + + if err := f.Finalize("test", merge, fix, base); err != nil { + t.Fatalf("Finalize should succeed reading prior state from trunk: %v", err) + } + + st := loadState(t, onDisk, "test") + if st.SHA != merge { + t.Errorf("state.sha = %q, want merge SHA %q", st.SHA, merge) + } + // The Previous snapshot must derive from the trunk prior SHA (base), proving + // the prior state came from trunk and not the empty env-branch view. + if len(st.Previous) != 1 || st.Previous[0].SHA != base { + t.Errorf("Previous snapshot = %+v, want one entry with prior trunk sha %q", st.Previous, base) + } +} + +// TestFinalize_TrunkIsSourceOfPriorState proves trunk wins over the working +// tree: the on-disk manifest records sha=A for the target env while trunk +// records sha=B. The snapshot's Previous entry must derive from B (trunk), not A. +func TestFinalize_TrunkIsSourceOfPriorState(t *testing.T) { + newScratchRepo(t) + shaA := commitFile(t, "a.txt", "one", "A on env branch") + shaB := commitFile(t, "b.txt", "two", "B on trunk") + fix := commitFile(t, "f.txt", "fix", "fix on trunk") + runGit(t, "branch", "env/test", shaA) + runGit(t, "checkout", "env/test") + merge := commitFile(t, "c.txt", "fixed", "cherry-pick") + runGit(t, "checkout", "main") + + // On-disk env-branch manifest records the stale sha=A. + onDisk := writeFinalizeManifest(t, []string{"dev", "test", "prod"}, map[string]envFixture{ + "dev": {sha: fix, version: "v1.4.0-rc.2"}, + "test": {sha: shaA, version: "v1.4.0-rc.2"}, + "prod": {sha: shaA, version: "v1.4.0-rc.2"}, + }) + + // Trunk records the real prior sha=B. + trunkBytes := []byte("ci:\n config:\n environments:\n" + + " - dev\n - test\n - prod\n" + + " state:\n" + + " test:\n sha: " + shaB + "\n version: v1.4.0-rc.2\n") + + f := newFinalizer(t, onDisk, + WithReleaseManager(&stubReleaseManager{}), + WithTagLister(stubTagLister{}), + WithStatePusher(&recordingPusher{}), + WithTrunkStateReader(&stubTrunkReader{bytes: trunkBytes}), + ) + if err := f.Finalize("test", merge, fix, shaB); err != nil { + t.Fatalf("Finalize: %v", err) + } + + st := loadState(t, onDisk, "test") + if len(st.Previous) != 1 { + t.Fatalf("expected one Previous snapshot, got %d: %+v", len(st.Previous), st.Previous) + } + if st.Previous[0].SHA != shaB { + t.Errorf("Previous snapshot sha = %q, want trunk prior sha %q (not on-disk %q)", st.Previous[0].SHA, shaB, shaA) + } +} + +// TestFinalize_TrunkStateAbsent_Errors confirms that when the target env has no +// recorded state on trunk either, finalize still reports the missing-state error +// rather than silently proceeding. +func TestFinalize_TrunkStateAbsent_Errors(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix") + runGit(t, "branch", "env/test", base) + runGit(t, "checkout", "env/test") + merge := commitFile(t, "c.txt", "fixed", "cp") + runGit(t, "checkout", "main") + + onDisk := writeFinalizeManifest(t, []string{"dev", "test", "prod"}, map[string]envFixture{ + "dev": {sha: fix, version: "v1.4.0-rc.2"}, + "test": {sha: base, version: "v1.4.0-rc.2"}, + "prod": {sha: base, version: "v1.4.0-rc.2"}, + }) + + // Trunk manifest has test present but with no sha recorded. + trunkBytes := []byte("ci:\n config:\n environments:\n" + + " - dev\n - test\n - prod\n" + + " state:\n" + + " dev:\n sha: " + fix + "\n version: v1.4.0-rc.2\n") + + f := newFinalizer(t, onDisk, + WithReleaseManager(&stubReleaseManager{}), + WithTagLister(stubTagLister{}), + WithStatePusher(&recordingPusher{}), + WithTrunkStateReader(&stubTrunkReader{bytes: trunkBytes}), + ) + err := f.Finalize("test", merge, fix, base) + if err == nil { + t.Fatal("expected missing-state error when trunk has no recorded SHA for the target env") + } + if !strings.Contains(err.Error(), "no recorded state SHA") { + t.Errorf("error %q should report the missing recorded state SHA", err.Error()) + } +} + // TestReleaseToken_FallsBackThroughEnvVars verifies the token resolution order: // RELEASE_TOKEN, then GITHUB_TOKEN, then GH_TOKEN. GH_TOKEN is the reliable // fallback because the runner does not always propagate the reserved From a683446a4a06196707b4824c64e7227e9a8b6d19 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 11:04:08 -0400 Subject: [PATCH 2/2] fix: write hotfix finalize state to the trunk manifest Finalize read prior env state from trunk but still wrote the checked-out env-branch manifest to trunk. On the pull_request closed run the checked-out tree is the env branch, whose state lags trunk because promote writes env state only to trunk. Serializing that lagging manifest to trunk wiped the non-target envs' recorded state. Finalize now uses the trunk manifest as the write basis: it reads trunk once, mutates only state[targetEnv] with the hotfix result, and writes that manifest back to trunk. Every other env's trunk state is preserved. The single trunk read feeds both the prior-state lookup and the write basis. Signed-off-by: Joshua Temple --- internal/hotfix/finalize.go | 45 ++++++++++-------- internal/hotfix/finalize_test.go | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index 4a6a0ce..cc55b8d 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -368,27 +368,30 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error trunk = "main" } - // Read prior env state from trunk, not from the checked-out env branch. - // Promote finalize writes env state only to trunk, so the env branch the - // hotfix merged into lags trunk and can record a stale or absent state SHA; - // trunk is the source of truth. The in-memory f.cicd remains the target of - // the WRITE below, preserving any env-branch-only edits to other fields. - trunkState, err := f.readTrunkState(trunk) + // Read the manifest as it exists on trunk, not from the checked-out env + // branch. Promote finalize writes env state only to trunk, so the env branch + // the hotfix merged into lags trunk and can record stale or absent state for + // every env; trunk is the source of truth. The trunk manifest becomes the + // 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) if err != nil { return err } - - prior := trunkState[targetEnv] - if prior == nil || prior.SHA == "" { - return fmt.Errorf("environment %q has no recorded state SHA", targetEnv) + f.cicd = trunkCICD + cfg = f.cicd.Config + if cfg == nil { + return fmt.Errorf("trunk manifest has no config block") } - // Carry the trunk-resolved prior state into the in-memory manifest so the - // snapshot, divergence fields, and substates are applied over trunk's view - // and persisted by the write below. + if f.cicd.State == nil { f.cicd.State = make(map[string]*config.EnvState) } - f.cicd.State[targetEnv] = prior + prior := f.cicd.State[targetEnv] + if prior == nil || prior.SHA == "" { + return fmt.Errorf("environment %q has no recorded state SHA", targetEnv) + } branch := envBranch(targetEnv) @@ -461,11 +464,13 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return nil } -// readTrunkState fetches the manifest as it exists on the trunk branch and -// returns its env state map. Prior env state must be read from trunk because -// promote finalize writes env state only to trunk; the env branch the hotfix -// merged into lags trunk and can record a stale or absent state SHA. -func (f *Finalizer) readTrunkState(trunk string) (map[string]*config.EnvState, error) { +// 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) { data, err := f.trunkReader.ReadManifest(f.configPath, trunk) if err != nil { return nil, fmt.Errorf("reading trunk state: %w", err) @@ -474,7 +479,7 @@ func (f *Finalizer) readTrunkState(trunk string) (map[string]*config.EnvState, e if err != nil { return nil, fmt.Errorf("parsing trunk manifest: %w", err) } - return cicd.State, nil + return cicd, nil } // allocateVersion returns the next free hotfix version over priorVersion. diff --git a/internal/hotfix/finalize_test.go b/internal/hotfix/finalize_test.go index e1199a7..334c282 100644 --- a/internal/hotfix/finalize_test.go +++ b/internal/hotfix/finalize_test.go @@ -589,6 +589,88 @@ func TestFinalize_TrunkIsSourceOfPriorState(t *testing.T) { } } +// TestFinalize_DoesNotClobberOtherEnvsTrunkState proves the WRITE basis is the +// trunk manifest, not the lagging env-branch one. Trunk records populated state +// for dev, staging (the target), and prod with distinct SHAs. The checked-out +// env-branch manifest carries empty/stale state for those envs (promote writes +// state only to trunk, so the env branch lags; post-reset it can be empty). +// +// After finalize, the written manifest must (a) update staging to the hotfix +// result and (b) RETAIN dev and prod exactly as trunk recorded them. Writing the +// env-branch manifest to trunk would wipe dev and prod; this guards against that. +func TestFinalize_DoesNotClobberOtherEnvsTrunkState(t *testing.T) { + newScratchRepo(t) + base := commitFile(t, "a.txt", "one", "first") + fix := commitFile(t, "b.txt", "two", "fix on trunk") + runGit(t, "branch", "env/staging", base) + runGit(t, "checkout", "env/staging") + merge := commitFile(t, "c.txt", "fixed", "cherry-pick fix") + runGit(t, "checkout", "main") + + devTrunkSHA := commitFile(t, "dev.txt", "dev", "dev trunk state") + prodTrunkSHA := commitFile(t, "prod.txt", "prod", "prod trunk state") + + // On-disk (env-branch) manifest: dev and prod carry stale/empty state, and + // staging is empty. This is the lagging tree that must NOT become the write + // basis. + onDisk := writeFinalizeManifest(t, []string{"dev", "staging", "prod"}, map[string]envFixture{ + "dev": {sha: "stale-dev", version: "v0.0.1"}, + "staging": {}, // empty on the lagging env branch + "prod": {}, // empty on the lagging env branch + }) + + // Trunk manifest: every env has real, distinct recorded state. + trunkBytes := []byte("ci:\n config:\n environments:\n" + + " - dev\n - staging\n - prod\n" + + " state:\n" + + " dev:\n sha: " + devTrunkSHA + "\n version: v1.4.0-rc.3\n" + + " staging:\n sha: " + base + "\n version: v1.4.0-rc.2\n" + + " prod:\n sha: " + prodTrunkSHA + "\n version: v1.4.0-rc.1\n") + + f := newFinalizer(t, onDisk, + WithReleaseManager(&stubReleaseManager{}), + WithTagLister(stubTagLister{}), + WithStatePusher(&recordingPusher{}), + WithTrunkStateReader(&stubTrunkReader{bytes: trunkBytes}), + ) + if err := f.Finalize("staging", merge, fix, base); err != nil { + t.Fatalf("Finalize: %v", err) + } + + // (a) staging updated to the hotfix result. + staging := loadState(t, onDisk, "staging") + if staging.SHA != merge { + t.Errorf("staging.sha = %q, want merge SHA %q", staging.SHA, merge) + } + if staging.Version != "v1.4.0-rc.2.hotfix.1" { + t.Errorf("staging.version = %q, want v1.4.0-rc.2.hotfix.1", staging.Version) + } + + // (b) dev and prod retain their TRUNK state, not the stale env-branch view. + dev := loadState(t, onDisk, "dev") + if dev == nil || dev.SHA != devTrunkSHA { + t.Errorf("dev.sha = %q, want trunk SHA %q (env-branch state must not clobber trunk)", devSHA(dev), devTrunkSHA) + } + if dev != nil && dev.Version != "v1.4.0-rc.3" { + t.Errorf("dev.version = %q, want trunk version v1.4.0-rc.3", dev.Version) + } + prod := loadState(t, onDisk, "prod") + if prod == nil || prod.SHA != prodTrunkSHA { + t.Errorf("prod.sha = %q, want trunk SHA %q (env-branch state must not clobber trunk)", devSHA(prod), prodTrunkSHA) + } + if prod != nil && prod.Version != "v1.4.0-rc.1" { + t.Errorf("prod.version = %q, want trunk version v1.4.0-rc.1", prod.Version) + } +} + +// devSHA returns the SHA of a possibly-nil EnvState for tidy error messages. +func devSHA(s *config.EnvState) string { + if s == nil { + return "" + } + return s.SHA +} + // TestFinalize_TrunkStateAbsent_Errors confirms that when the target env has no // recorded state on trunk either, finalize still reports the missing-state error // rather than silently proceeding.