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
46 changes: 46 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os/exec"
"strings"
"time"
)

// GetChangedFiles returns the list of files changed between two commits
Expand Down Expand Up @@ -160,6 +161,51 @@ func GetLatestTag(prefix string) (string, string, error) {
return latestTag, strings.TrimSpace(string(output)), nil
}

// ListTags returns every tag in the repository. It returns an empty slice when
// the repository has no tags.
func ListTags() ([]string, error) {
cmd := exec.Command("git", "tag", "-l")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git tag -l: %w", err)
}
return parseLines(output), nil
}

// CommitAndPushWithRetry stages filePath, commits it with message, and pushes
// to the current branch's upstream, retrying the push up to three times behind a
// pull --rebase. A "nothing to commit" state is treated as success (no-op). This
// is the manifest state-write path shared by promote and hotfix finalize: an
// API-created commit on real GitHub goes through a different path, so this is the
// plain-git fallback used when committing locally.
func CommitAndPushWithRetry(filePath, message string) error {
cmd := exec.Command("git", "add", filePath)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add failed: %s: %w", string(out), err)
}

cmd = exec.Command("git", "commit", "-m", message)
if out, err := cmd.CombinedOutput(); err != nil {
if strings.Contains(string(out), "nothing to commit") {
return nil
}
return fmt.Errorf("git commit failed: %s: %w", string(out), err)
}

for i := 0; i < 3; i++ {
cmd = exec.Command("git", "push")
if _, err := cmd.CombinedOutput(); err == nil {
return nil
}

cmd = exec.Command("git", "pull", "--rebase")
_, _ = cmd.CombinedOutput() // ignore error - best effort
time.Sleep(2 * time.Second)
}

return fmt.Errorf("git push failed after 3 retries")
}

// CommitAndPush stages a file, commits with the given message, and pushes to origin.
func CommitAndPush(filePath, message string) error {
// Stage the file
Expand Down
90 changes: 90 additions & 0 deletions internal/hotfix/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,99 @@ state write run in the generated workflow.`,
}

cmd.AddCommand(newPlanCommand())
cmd.AddCommand(newFinalizeCommand())
return cmd
}

// newFinalizeCommand creates the `cascade hotfix finalize` subcommand.
func newFinalizeCommand() *cobra.Command {
var (
configPath string
manifestKey string
targetEnv string
mergeSHA string
fixSHA string
baseSHA string
actor string
dryRun bool
deployFlags []string
buildFlags []string
)

cmd := &cobra.Command{
Use: "finalize",
Short: "Write diverged state, tag, and release for a merged hotfix",
Long: `Finalize a completed hotfix.

After the resolution PR merges and the build and deploy succeed, this command:
1. Cross-checks the merge SHA equals the env/<target> branch tip
2. Allocates the next free hotfix version over the env's current version
3. Snapshots the prior env state into the rollback ring
4. Writes the diverged state (sha, version, ref, base_sha, patches) and substates
5. Commits the manifest to trunk with the rebase-retry push
6. Creates the hotfix tag and release object

The verb is idempotent on identical inputs: a rerun after the state already
records the merge SHA is a no-op.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts := []FinalizeOption{WithFinalizeDryRun(dryRun)}

finalizer, err := NewFinalizer(FinalizerOptions{
ConfigPath: configPath,
ManifestKey: manifestKey,
Actor: actor,
}, opts...)
if err != nil {
return err
}

for _, df := range deployFlags {
name, result, ok := splitResultFlag(df)
if !ok {
return fmt.Errorf("invalid --deploy-result %q: want name=result", df)
}
finalizer.SetDeployResult(name, result)
}
for _, bf := range buildFlags {
name, result, ok := splitResultFlag(bf)
if !ok {
return fmt.Errorf("invalid --build-result %q: want name=result", bf)
}
finalizer.SetBuildResult(name, result)
}

return finalizer.Finalize(targetEnv, mergeSHA, fixSHA, baseSHA)
},
}

cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to manifest file (default: .github/manifest.yaml)")
cmd.Flags().StringVar(&manifestKey, "key", config.DefaultManifestKey, "Top-level manifest key")
cmd.Flags().StringVar(&targetEnv, "target-env", "", "Environment to finalize (required)")
cmd.Flags().StringVar(&mergeSHA, "merge-sha", "", "Tip of env/<target> after the resolution PR merged (required)")
cmd.Flags().StringVar(&fixSHA, "fix-sha", "", "Trunk commit the hotfix carries (required)")
cmd.Flags().StringVar(&baseSHA, "base-sha", "", "Trunk anchor the integration branch diverged from (required)")
cmd.Flags().StringVar(&actor, "actor", "", "Actor recorded on the state (default: $GITHUB_ACTOR)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and compute without writing state, tags, or releases")
cmd.Flags().StringArrayVar(&deployFlags, "deploy-result", nil, "Deploy result as name=result (repeatable)")
cmd.Flags().StringArrayVar(&buildFlags, "build-result", nil, "Build result as name=result (repeatable)")

_ = cmd.MarkFlagRequired("target-env")
_ = cmd.MarkFlagRequired("merge-sha")
_ = cmd.MarkFlagRequired("fix-sha")
_ = cmd.MarkFlagRequired("base-sha")

return cmd
}

// splitResultFlag parses a "name=result" job-result flag value.
func splitResultFlag(s string) (name, result string, ok bool) {
idx := strings.IndexByte(s, '=')
if idx <= 0 || idx == len(s)-1 {
return "", "", false
}
return s[:idx], s[idx+1:], true
}

// newPlanCommand creates the `cascade hotfix plan` subcommand.
func newPlanCommand() *cobra.Command {
var (
Expand Down
Loading
Loading