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
1 change: 1 addition & 0 deletions cmd/ci/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ var CICmd = &model.CommandGroup{
tagCmd,
ciTestCmd,
logResultCmd,
resolveBranchCmd,
},
}
55 changes: 55 additions & 0 deletions cmd/ci/resolve_branch.go
Original file line number Diff line number Diff line change
@@ -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()
}
44 changes: 44 additions & 0 deletions internal/ci/actions/resolveBranch.go
Original file line number Diff line number Diff line change
@@ -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)
}
11 changes: 9 additions & 2 deletions internal/ci/actions/runWorkflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
78 changes: 71 additions & 7 deletions internal/ci/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<lang>/ 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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
}
Expand Down
Loading