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 }