diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index a2a0db710..13f6e7bb2 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -23,5 +23,6 @@ var CICmd = &model.CommandGroup{ tagCmd, ciTestCmd, logResultCmd, + resolveBranchCmd, }, } diff --git a/cmd/ci/resolve_branch.go b/cmd/ci/resolve_branch.go new file mode 100644 index 000000000..d906035e8 --- /dev/null +++ b/cmd/ci/resolve_branch.go @@ -0,0 +1,55 @@ +package ci + +import ( + "context" + "os" + + "github.com/speakeasy-api/speakeasy/internal/ci/actions" + "github.com/speakeasy-api/speakeasy/internal/model" + "github.com/speakeasy-api/speakeasy/internal/model/flag" +) + +type resolveBranchFlags struct { + GithubAccessToken string `json:"github-access-token"` + Mode string `json:"mode"` + FeatureBranch string `json:"feature-branch"` + Debug bool `json:"debug"` +} + +var resolveBranchCmd = &model.ExecutableCommand[resolveBranchFlags]{ + Usage: "resolve-branch", + Short: "Resolve the target branch for SDK generation (used by CI/CD)", + Long: "Finds an existing open PR branch or creates a new one. Outputs branch_name for use by parallel matrix jobs via INPUT_BRANCH_NAME.", + Run: runResolveBranch, + Flags: []flag.Flag{ + flag.StringFlag{ + Name: "github-access-token", + Description: "GitHub access token for repository operations", + DefaultValue: os.Getenv("INPUT_GITHUB_ACCESS_TOKEN"), + }, + flag.StringFlag{ + Name: "mode", + Description: "Generation mode: direct, pr, or test", + DefaultValue: os.Getenv("INPUT_MODE"), + }, + flag.StringFlag{ + Name: "feature-branch", + Description: "Feature branch name for PR mode", + DefaultValue: os.Getenv("INPUT_FEATURE_BRANCH"), + }, + flag.BooleanFlag{ + Name: "debug", + Description: "Enable debug logging", + DefaultValue: os.Getenv("INPUT_DEBUG") == "true", + }, + }, +} + +func runResolveBranch(ctx context.Context, flags resolveBranchFlags) error { + setEnvIfNotEmpty("INPUT_GITHUB_ACCESS_TOKEN", flags.GithubAccessToken) + setEnvIfNotEmpty("INPUT_MODE", flags.Mode) + setEnvIfNotEmpty("INPUT_FEATURE_BRANCH", flags.FeatureBranch) + setEnvBool("INPUT_DEBUG", flags.Debug) + + return actions.ResolveBranch() +} diff --git a/internal/ci/actions/resolveBranch.go b/internal/ci/actions/resolveBranch.go new file mode 100644 index 000000000..de1ee0b10 --- /dev/null +++ b/internal/ci/actions/resolveBranch.go @@ -0,0 +1,44 @@ +package actions + +import ( + "github.com/speakeasy-api/speakeasy/internal/ci/environment" + "github.com/speakeasy-api/speakeasy/internal/ci/logging" +) + +// ResolveBranch finds an existing PR branch or creates a new one. +// It outputs the branch_name for use by downstream matrix jobs via INPUT_BRANCH_NAME. +// This is intended to be called once in a "prep" job before fanning out to parallel targets. +func ResolveBranch() error { + g, err := initAction() + if err != nil { + return err + } + + sourcesOnly := false // resolve-branch is only used for SDK generation workflows + + // Look for an existing open PR to reuse its branch + branchName, _, err := g.FindExistingPR(environment.GetFeatureBranch(), environment.ActionRunWorkflow, sourcesOnly) + if err != nil { + return err + } + + // If no existing PR was found, create a new branch + if branchName == "" { + branchName, err = g.FindOrCreateBranch("", environment.ActionRunWorkflow) + if err != nil { + return err + } + } else { + // Existing PR found — check out its branch + logging.Info("Reusing existing PR branch: %s", branchName) + if _, err := g.FindAndCheckoutBranch(branchName); err != nil { + return err + } + } + + outputs := map[string]string{ + "branch_name": branchName, + } + + return setOutputs(outputs) +} diff --git a/internal/ci/actions/runWorkflow.go b/internal/ci/actions/runWorkflow.go index 87d410159..b2f8f07ae 100644 --- a/internal/ci/actions/runWorkflow.go +++ b/internal/ci/actions/runWorkflow.go @@ -63,9 +63,11 @@ func RunWorkflow(ctx context.Context) error { sourcesOnly := len(wf.Targets) == 0 - branchName := "" + // Honour explicit branch name (e.g. from matrix workflow prep job via INPUT_BRANCH_NAME). + // When set, we skip PR-based branch discovery and use the provided branch directly. + branchName := environment.GetBranchName() var pr *github.PullRequest - if mode == environment.ModePR { + if branchName == "" && mode == environment.ModePR { var err error branchName, pr, err = g.FindExistingPR(environment.GetFeatureBranch(), environment.ActionRunWorkflow, sourcesOnly) if err != nil { @@ -237,6 +239,11 @@ func shouldDeleteBranch(success bool) bool { return false } + // Never delete when branch was explicitly provided (e.g. matrix workflow) + if environment.GetBranchName() != "" { + return false + } + // Keep branches during debug or test runs if environment.IsDebugMode() || environment.IsTestMode() { return false diff --git a/internal/ci/git/git.go b/internal/ci/git/git.go index 7704dc9f9..89f0c008e 100644 --- a/internal/ci/git/git.go +++ b/internal/ci/git/git.go @@ -201,7 +201,9 @@ func (g *Git) FindExistingPR(branchName string, action environment.Action, sourc var prTitle string switch action { //nolint:exhaustive case environment.ActionRunWorkflow, environment.ActionFinalize: - prTitle = getGenPRTitlePrefix() + // Use the search prefix (without target name) so matrix jobs with different + // INPUT_TARGET values can find the same shared PR. + prTitle = getGenPRTitleSearchPrefix() if sourceGeneration { prTitle = getGenSourcesTitlePrefix() } @@ -349,6 +351,13 @@ func (g *Git) FindOrCreateBranch(branchName string, action environment.Action) ( existingBranch, err := g.FindAndCheckoutBranch(branchName) if err == nil { + // When INPUT_BRANCH_NAME is set (matrix workflow), the branch is CI-owned and shared + // across parallel jobs. Don't reset to main — preserve commits from earlier matrix jobs. + if environment.GetBranchName() != "" { + logging.Info("Using explicit branch %s (matrix mode), preserving existing commits", branchName) + return existingBranch, nil + } + // Find non-CI commits that should be preserved nonCICommits, err := g.findNonCICommits(branchName, defaultBranch) if err != nil { @@ -614,11 +623,20 @@ func (g *Git) CommitAndPush(openAPIDocVersion, speakeasyVersion, doc string, act return "", fmt.Errorf("error committing changes: %w", err) } - if err := g.repo.Push(&git.PushOptions{ - Auth: sharedgit.BasicAuth(g.accessToken), - Force: true, // This is necessary because at the beginning of the workflow we reset the branch - }); err != nil { - return "", pushErr(err) + if environment.GetBranchName() != "" { + // Matrix mode: rebase onto remote then push without force. + // Each matrix job targets a different dist// directory so rebases succeed cleanly. + if err := g.rebaseAndPush(); err != nil { + return "", err + } + } else { + // Default: force push (branch was reset to main at the beginning of the workflow) + if err := g.repo.Push(&git.PushOptions{ + Auth: sharedgit.BasicAuth(g.accessToken), + Force: true, + }); err != nil { + return "", pushErr(err) + } } return commitHash.String(), nil } @@ -688,6 +706,44 @@ func (g *Git) CommitAndPush(openAPIDocVersion, speakeasyVersion, doc string, act return *commitResult.SHA, nil } +// rebaseAndPush fetches the latest remote branch, rebases the local commit(s) on top, +// and pushes. This is used in matrix mode (INPUT_BRANCH_NAME set) where multiple parallel +// jobs push to the same branch. Each job targets a different output directory so rebases +// succeed without conflicts. Retries up to 3 times on non-fast-forward errors. +func (g *Git) rebaseAndPush() error { + dir := filepath.Join(g.repoRoot, environment.GetWorkingDirectory()) + branch, err := g.GetCurrentBranch() + if err != nil { + return fmt.Errorf("error getting current branch for rebase: %w", err) + } + + const maxRetries = 3 + for attempt := 0; attempt < maxRetries; attempt++ { + // Fetch latest from remote + if _, err := sharedgit.RunGitCommand(dir, "fetch", "origin", branch); err != nil { + logging.Info("fetch failed (attempt %d): %v", attempt+1, err) + } + + // Rebase local commits onto remote + if _, err := sharedgit.RunGitCommand(dir, "rebase", "origin/"+branch); err != nil { + return fmt.Errorf("error rebasing onto origin/%s: %w", branch, err) + } + + // Push without force + if _, err := sharedgit.RunGitCommand(dir, "push", "origin", branch); err != nil { + if attempt < maxRetries-1 { + logging.Info("push failed (attempt %d), retrying after fetch+rebase: %v", attempt+1, err) + continue + } + return fmt.Errorf("error pushing after %d attempts: %w", maxRetries, err) + } + + return nil + } + + return nil +} + // getOrCreateRef returns the commit branch reference object if it exists or creates it // from the base branch before returning it. func (g *Git) getOrCreateRef(commitRef string) (ref *github.Reference, err error) { @@ -1614,8 +1670,16 @@ const ( speakeasyDocsPRTitle = "chore: 🐝 Update SDK Docs - " ) +// getGenPRTitleSearchPrefix returns the prefix used by FindExistingPR to match existing PRs. +// It deliberately excludes the target name so that matrix jobs (each with a different INPUT_TARGET) +// can find and update the same shared PR. +func getGenPRTitleSearchPrefix() string { + return speakeasyGenPRTitle + environment.GetWorkflowName() +} + +// getGenPRTitlePrefix returns the full title prefix for PR creation/update, including the target name. func getGenPRTitlePrefix() string { - title := speakeasyGenPRTitle + environment.GetWorkflowName() + title := getGenPRTitleSearchPrefix() if environment.SpecifiedTarget() != "" && !strings.Contains(title, strings.ToUpper(environment.SpecifiedTarget())) { title += " " + strings.ToUpper(environment.SpecifiedTarget()) }