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..cc55b8d 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,6 +361,33 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return fmt.Errorf("%q is not a configured environment", 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 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 + } + f.cicd = trunkCICD + cfg = f.cicd.Config + if cfg == nil { + return fmt.Errorf("trunk manifest has no config block") + } + + if f.cicd.State == nil { + f.cicd.State = make(map[string]*config.EnvState) + } prior := f.cicd.State[targetEnv] if prior == nil || prior.SHA == "" { return fmt.Errorf("environment %q has no recorded state SHA", targetEnv) @@ -358,10 +451,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 +464,24 @@ func (f *Finalizer) Finalize(targetEnv, mergeSHA, fixSHA, baseSHA string) error return nil } +// 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) + } + cicd, err := config.ParseManifestBytes(data, f.manifestKey) + if err != nil { + return nil, fmt.Errorf("parsing trunk manifest: %w", err) + } + return cicd, 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..334c282 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,225 @@ 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_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. +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