github.com/goreleaser/goreleaser@v1.25.1/internal/client/git.go (about)

     1  package client
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"strings"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/caarlos0/log"
    14  	"github.com/charmbracelet/x/exp/ordered"
    15  	"github.com/goreleaser/goreleaser/internal/git"
    16  	"github.com/goreleaser/goreleaser/internal/pipe"
    17  	"github.com/goreleaser/goreleaser/internal/tmpl"
    18  	"github.com/goreleaser/goreleaser/pkg/config"
    19  	"github.com/goreleaser/goreleaser/pkg/context"
    20  	"golang.org/x/crypto/ssh"
    21  )
    22  
    23  var gil sync.Mutex
    24  
    25  // DefaulGitSSHCommand used for git over SSH.
    26  const DefaulGitSSHCommand = `ssh -i "{{ .KeyPath }}" -o StrictHostKeyChecking=accept-new -F /dev/null`
    27  
    28  type gitClient struct {
    29  	branch string
    30  }
    31  
    32  // NewGitUploadClient
    33  func NewGitUploadClient(branch string) FilesCreator {
    34  	return &gitClient{
    35  		branch: branch,
    36  	}
    37  }
    38  
    39  // CreateFiles implements FilesCreator.
    40  func (g *gitClient) CreateFiles(
    41  	ctx *context.Context,
    42  	commitAuthor config.CommitAuthor,
    43  	repo Repo,
    44  	message string,
    45  	files []RepoFile,
    46  ) (err error) {
    47  	gil.Lock()
    48  	defer gil.Unlock()
    49  
    50  	url, err := tmpl.New(ctx).Apply(repo.GitURL)
    51  	if err != nil {
    52  		return fmt.Errorf("git: failed to template git url: %w", err)
    53  	}
    54  
    55  	if url == "" {
    56  		return pipe.Skip("url is empty")
    57  	}
    58  
    59  	repo.Name = ordered.First(repo.Name, nameFromURL(url))
    60  
    61  	key, err := tmpl.New(ctx).Apply(repo.PrivateKey)
    62  	if err != nil {
    63  		return fmt.Errorf("git: failed to template private key: %w", err)
    64  	}
    65  
    66  	key, err = keyPath(key)
    67  	if err != nil {
    68  		return err
    69  	}
    70  
    71  	sshcmd, err := tmpl.New(ctx).WithExtraFields(tmpl.Fields{
    72  		"KeyPath": key,
    73  	}).Apply(ordered.First(repo.GitSSHCommand, DefaulGitSSHCommand))
    74  	if err != nil {
    75  		return fmt.Errorf("git: failed to template ssh command: %w", err)
    76  	}
    77  
    78  	parent := filepath.Join(ctx.Config.Dist, "git")
    79  	name := repo.Name + "-" + g.branch
    80  	cwd := filepath.Join(parent, name)
    81  	env := []string{fmt.Sprintf("GIT_SSH_COMMAND=%s", sshcmd)}
    82  
    83  	if _, err := os.Stat(cwd); errors.Is(err, os.ErrNotExist) {
    84  		log.Infof("cloning %s %s", name, cwd)
    85  		if err := os.MkdirAll(parent, 0o755); err != nil {
    86  			return fmt.Errorf("git: failed to create parent: %w", err)
    87  		}
    88  
    89  		if err := cloneRepoWithRetries(ctx, parent, url, name, env); err != nil {
    90  			return err
    91  		}
    92  
    93  		if err := runGitCmds(ctx, cwd, env, [][]string{
    94  			{"config", "--local", "user.name", commitAuthor.Name},
    95  			{"config", "--local", "user.email", commitAuthor.Email},
    96  			{"config", "--local", "commit.gpgSign", "false"},
    97  			{"config", "--local", "init.defaultBranch", ordered.First(g.branch, "master")},
    98  		}); err != nil {
    99  			return fmt.Errorf("git: failed to setup local repository: %w", err)
   100  		}
   101  		if g.branch != "" {
   102  			if err := runGitCmds(ctx, cwd, env, [][]string{
   103  				{"checkout", g.branch},
   104  			}); err != nil {
   105  				if err := runGitCmds(ctx, cwd, env, [][]string{
   106  					{"checkout", "-b", g.branch},
   107  				}); err != nil {
   108  					return fmt.Errorf("git: could not checkout branch %s: %w", g.branch, err)
   109  				}
   110  			}
   111  		}
   112  	}
   113  
   114  	for _, file := range files {
   115  		location := filepath.Join(cwd, file.Path)
   116  		log.WithField("path", location).Info("writing")
   117  		if err := os.MkdirAll(filepath.Dir(location), 0o755); err != nil {
   118  			return fmt.Errorf("failed to create parent dirs for %s: %w", file.Path, err)
   119  		}
   120  		if err := os.WriteFile(location, file.Content, 0o644); err != nil {
   121  			return fmt.Errorf("failed to write %s: %w", file.Path, err)
   122  		}
   123  		log.
   124  			WithField("repository", url).
   125  			WithField("name", repo.Name).
   126  			WithField("file", file.Path).
   127  			Info("pushing")
   128  	}
   129  
   130  	if err := runGitCmds(ctx, cwd, env, [][]string{
   131  		{"add", "-A", "."},
   132  		{"commit", "-m", message},
   133  		{"push", "origin", "HEAD"},
   134  	}); err != nil {
   135  		return fmt.Errorf("git: failed to push %q (%q): %w", repo.Name, url, err)
   136  	}
   137  
   138  	return nil
   139  }
   140  
   141  // CreateFile implements FileCreator.
   142  func (g *gitClient) CreateFile(ctx *context.Context, commitAuthor config.CommitAuthor, repo Repo, content []byte, path string, message string) error {
   143  	return g.CreateFiles(ctx, commitAuthor, repo, message, []RepoFile{{
   144  		Path:    path,
   145  		Content: content,
   146  	}})
   147  }
   148  
   149  func keyPath(key string) (string, error) {
   150  	if key == "" {
   151  		return "", pipe.Skip("private_key is empty")
   152  	}
   153  
   154  	path := key
   155  
   156  	_, err := ssh.ParsePrivateKey([]byte(key))
   157  	if isPasswordError(err) {
   158  		return "", fmt.Errorf("git: key is password-protected")
   159  	}
   160  
   161  	if err == nil {
   162  		// if it can be parsed as a valid private key, we write it to a
   163  		// temp file and use that path on GIT_SSH_COMMAND.
   164  		f, err := os.CreateTemp("", "id_*")
   165  		if err != nil {
   166  			return "", fmt.Errorf("git: failed to store private key: %w", err)
   167  		}
   168  		defer f.Close()
   169  
   170  		// the key needs to EOF at an empty line, seems like github actions
   171  		// is somehow removing them.
   172  		if !strings.HasSuffix(key, "\n") {
   173  			key += "\n"
   174  		}
   175  
   176  		if _, err := io.WriteString(f, key); err != nil {
   177  			return "", fmt.Errorf("git: failed to store private key: %w", err)
   178  		}
   179  		if err := f.Close(); err != nil {
   180  			return "", fmt.Errorf("git: failed to store private key: %w", err)
   181  		}
   182  		path = f.Name()
   183  	}
   184  
   185  	if _, err := os.Stat(path); err != nil {
   186  		return "", fmt.Errorf("git: could not stat private_key: %w", err)
   187  	}
   188  
   189  	// in any case, ensure the key has the correct permissions.
   190  	if err := os.Chmod(path, 0o600); err != nil {
   191  		return "", fmt.Errorf("git: failed to ensure private_key permissions: %w", err)
   192  	}
   193  
   194  	return path, nil
   195  }
   196  
   197  func isPasswordError(err error) bool {
   198  	var kerr *ssh.PassphraseMissingError
   199  	return errors.As(err, &kerr)
   200  }
   201  
   202  func cloneRepoWithRetries(ctx *context.Context, parent, url, name string, env []string) error {
   203  	var try int
   204  	for try < 10 {
   205  		try++
   206  		err := runGitCmds(ctx, parent, env, [][]string{{"clone", url, name}})
   207  		if err == nil {
   208  			return nil
   209  		}
   210  		if isRetriableCloneError(err) {
   211  			log.WithField("try", try).
   212  				WithField("image", name).
   213  				WithError(err).
   214  				Warnf("failed to push image, will retry")
   215  			time.Sleep(time.Duration(try*10) * time.Second)
   216  			continue
   217  		}
   218  		return fmt.Errorf("failed to clone local repository: %w", err)
   219  	}
   220  	return fmt.Errorf("failed to push %s after %d tries", name, try)
   221  }
   222  
   223  func isRetriableCloneError(err error) bool {
   224  	return strings.Contains(err.Error(), "Connection reset")
   225  }
   226  
   227  func runGitCmds(ctx *context.Context, cwd string, env []string, cmds [][]string) error {
   228  	for _, cmd := range cmds {
   229  		args := append([]string{"-C", cwd}, cmd...)
   230  		if _, err := git.Clean(git.RunWithEnv(ctx, env, args...)); err != nil {
   231  			return fmt.Errorf("%q failed: %w", strings.Join(cmd, " "), err)
   232  		}
   233  	}
   234  	return nil
   235  }
   236  
   237  func nameFromURL(url string) string {
   238  	return strings.TrimSuffix(url[strings.LastIndex(url, "/")+1:], ".git")
   239  }