Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 115 additions & 8 deletions internal/hotfix/finalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading