github.com/amane3/goreleaser@v0.182.0/internal/pipe/brew/brew.go (about) 1 package brew 2 3 import ( 4 "bufio" 5 "bytes" 6 "errors" 7 "fmt" 8 "io/ioutil" 9 "path" 10 "path/filepath" 11 "strings" 12 "text/template" 13 14 "github.com/amane3/goreleaser/internal/artifact" 15 "github.com/amane3/goreleaser/internal/client" 16 "github.com/amane3/goreleaser/internal/deprecate" 17 "github.com/amane3/goreleaser/internal/pipe" 18 "github.com/amane3/goreleaser/internal/tmpl" 19 "github.com/amane3/goreleaser/pkg/config" 20 "github.com/amane3/goreleaser/pkg/context" 21 "github.com/apex/log" 22 ) 23 24 // ErrNoArchivesFound happens when 0 archives are found. 25 var ErrNoArchivesFound = errors.New("no linux/macos archives found") 26 27 // ErrMultipleArchivesSameOS happens when the config yields multiple archives 28 // for linux or windows. 29 var ErrMultipleArchivesSameOS = errors.New("one tap can handle only archive of an OS/Arch combination. Consider using ids in the brew section") 30 31 // ErrTokenTypeNotImplementedForBrew indicates that a new token type was not implemented for this pipe. 32 type ErrTokenTypeNotImplementedForBrew struct { 33 TokenType context.TokenType 34 } 35 36 func (e ErrTokenTypeNotImplementedForBrew) Error() string { 37 if e.TokenType != "" { 38 return fmt.Sprintf("token type %q not implemented for brew pipe", e.TokenType) 39 } 40 return "token type not implemented for brew pipe" 41 } 42 43 // Pipe for brew deployment. 44 type Pipe struct{} 45 46 func (Pipe) String() string { 47 return "homebrew tap formula" 48 } 49 50 // Publish brew formula. 51 func (Pipe) Publish(ctx *context.Context) error { 52 // we keep GitHub as default for now, in line with releases 53 if string(ctx.TokenType) == "" { 54 ctx.TokenType = context.TokenTypeGitHub 55 } 56 57 cli, err := client.New(ctx) 58 if err != nil { 59 return err 60 } 61 return publishAll(ctx, cli) 62 } 63 64 func publishAll(ctx *context.Context, cli client.Client) error { 65 // even if one of them skips, we run them all, and then show return the skips all at once. 66 // this is needed so we actually create the `dist/foo.rb` file, which is useful for debugging. 67 var skips = pipe.SkipMemento{} 68 for _, brew := range ctx.Config.Brews { 69 var err = doRun(ctx, brew, cli) 70 if err != nil && pipe.IsSkip(err) { 71 skips.Remember(err) 72 continue 73 } 74 if err != nil { 75 return err 76 } 77 } 78 return skips.Evaluate() 79 } 80 81 // Default sets the pipe defaults. 82 func (Pipe) Default(ctx *context.Context) error { 83 for i := range ctx.Config.Brews { 84 var brew = &ctx.Config.Brews[i] 85 86 if brew.Install == "" { 87 // TODO: maybe replace this with a simpler also optimistic 88 // approach of just doing `bin.install "project_name"`? 89 var installs []string 90 for _, build := range ctx.Config.Builds { 91 if !isBrewBuild(build) { 92 continue 93 } 94 install := fmt.Sprintf(`bin.install "%s"`, build.Binary) 95 // Do not add duplicate "bin.install" statements when binary names overlap. 96 var found bool 97 for _, instruction := range installs { 98 if instruction == install { 99 found = true 100 break 101 } 102 } 103 if !found { 104 installs = append(installs, install) 105 } 106 } 107 brew.Install = strings.Join(installs, "\n") 108 log.Warnf("optimistically guessing `brew[%d].install`, double check", i) 109 } 110 111 //nolint: staticcheck 112 if brew.GitHub.String() != "" { 113 deprecate.Notice(ctx, "brews.github") 114 brew.Tap.Owner = brew.GitHub.Owner 115 brew.Tap.Name = brew.GitHub.Name 116 } 117 if brew.GitLab.String() != "" { 118 deprecate.Notice(ctx, "brews.gitlab") 119 brew.Tap.Owner = brew.GitLab.Owner 120 brew.Tap.Name = brew.GitLab.Name 121 } 122 if brew.CommitAuthor.Name == "" { 123 brew.CommitAuthor.Name = "goreleaserbot" 124 } 125 if brew.CommitAuthor.Email == "" { 126 brew.CommitAuthor.Email = "goreleaser@carlosbecker.com" 127 } 128 if brew.Name == "" { 129 brew.Name = ctx.Config.ProjectName 130 } 131 if brew.Goarm == "" { 132 brew.Goarm = "6" 133 } 134 } 135 136 return nil 137 } 138 139 func isBrewBuild(build config.Build) bool { 140 for _, ignore := range build.Ignore { 141 if ignore.Goos == "darwin" && ignore.Goarch == "amd64" { 142 return false 143 } 144 } 145 return contains(build.Goos, "darwin") && contains(build.Goarch, "amd64") 146 } 147 148 func contains(ss []string, s string) bool { 149 for _, zs := range ss { 150 if zs == s { 151 return true 152 } 153 } 154 return false 155 } 156 157 func doRun(ctx *context.Context, brew config.Homebrew, cl client.Client) error { 158 if brew.Tap.Name == "" { 159 return pipe.Skip("brew section is not configured") 160 } 161 162 if brew.Tap.Token != "" { 163 token, err := tmpl.New(ctx).ApplySingleEnvOnly(brew.Tap.Token) 164 if err != nil { 165 return err 166 } 167 log.Debug("using custom token to publish homebrew formula") 168 c, err := client.NewWithToken(ctx, token) 169 if err != nil { 170 return err 171 } 172 cl = c 173 } 174 175 // TODO: properly cover this with tests 176 var filters = []artifact.Filter{ 177 artifact.Or( 178 artifact.ByGoos("darwin"), 179 artifact.ByGoos("linux"), 180 ), 181 artifact.ByFormats("zip", "tar.gz"), 182 artifact.Or( 183 artifact.ByGoarch("amd64"), 184 artifact.ByGoarch("arm64"), 185 artifact.And( 186 artifact.ByGoarch("arm"), 187 artifact.ByGoarm(brew.Goarm), 188 ), 189 ), 190 artifact.ByType(artifact.UploadableArchive), 191 } 192 if len(brew.IDs) > 0 { 193 filters = append(filters, artifact.ByIDs(brew.IDs...)) 194 } 195 196 var archives = ctx.Artifacts.Filter(artifact.And(filters...)).List() 197 if len(archives) == 0 { 198 return ErrNoArchivesFound 199 } 200 201 name, err := tmpl.New(ctx).Apply(brew.Name) 202 if err != nil { 203 return err 204 } 205 brew.Name = name 206 207 content, err := buildFormula(ctx, brew, cl, archives) 208 if err != nil { 209 return err 210 } 211 212 var filename = brew.Name + ".rb" 213 var path = filepath.Join(ctx.Config.Dist, filename) 214 log.WithField("formula", path).Info("writing") 215 if err := ioutil.WriteFile(path, []byte(content), 0644); err != nil { //nolint: gosec 216 return fmt.Errorf("failed to write brew formula: %w", err) 217 } 218 219 if strings.TrimSpace(brew.SkipUpload) == "true" { 220 return pipe.Skip("brew.skip_upload is set") 221 } 222 if ctx.SkipPublish { 223 return pipe.ErrSkipPublishEnabled 224 } 225 if strings.TrimSpace(brew.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" { 226 return pipe.Skip("prerelease detected with 'auto' upload, skipping homebrew publish") 227 } 228 229 repo := client.RepoFromRef(brew.Tap) 230 231 var gpath = buildFormulaPath(brew.Folder, filename) 232 log.WithField("formula", gpath). 233 WithField("repo", repo.String()). 234 Info("pushing") 235 236 var msg = fmt.Sprintf("Brew formula update for %s version %s", ctx.Config.ProjectName, ctx.Git.CurrentTag) 237 return cl.CreateFile(ctx, brew.CommitAuthor, repo, []byte(content), gpath, msg) 238 } 239 240 func buildFormulaPath(folder, filename string) string { 241 return path.Join(folder, filename) 242 } 243 244 func buildFormula(ctx *context.Context, brew config.Homebrew, client client.Client, artifacts []*artifact.Artifact) (string, error) { 245 data, err := dataFor(ctx, brew, client, artifacts) 246 if err != nil { 247 return "", err 248 } 249 return doBuildFormula(ctx, data) 250 } 251 252 func doBuildFormula(ctx *context.Context, data templateData) (string, error) { 253 t, err := template.New(data.Name).Parse(formulaTemplate) 254 if err != nil { 255 return "", err 256 } 257 var out bytes.Buffer 258 if err := t.Execute(&out, data); err != nil { 259 return "", err 260 } 261 262 content, err := tmpl.New(ctx).Apply(out.String()) 263 if err != nil { 264 return "", err 265 } 266 out.Reset() 267 268 // Sanitize the template output and get rid of trailing whitespace. 269 var ( 270 r = strings.NewReader(content) 271 s = bufio.NewScanner(r) 272 ) 273 for s.Scan() { 274 l := strings.TrimRight(s.Text(), " ") 275 _, _ = out.WriteString(l) 276 _ = out.WriteByte('\n') 277 } 278 if err := s.Err(); err != nil { 279 return "", err 280 } 281 282 return out.String(), nil 283 } 284 285 func dataFor(ctx *context.Context, cfg config.Homebrew, cl client.Client, artifacts []*artifact.Artifact) (templateData, error) { 286 var result = templateData{ 287 Name: formulaNameFor(cfg.Name), 288 Desc: cfg.Description, 289 Homepage: cfg.Homepage, 290 Version: ctx.Version, 291 License: cfg.License, 292 Caveats: split(cfg.Caveats), 293 Dependencies: cfg.Dependencies, 294 Conflicts: cfg.Conflicts, 295 Plist: cfg.Plist, 296 Install: split(cfg.Install), 297 PostInstall: cfg.PostInstall, 298 Tests: split(cfg.Test), 299 DownloadStrategy: cfg.DownloadStrategy, 300 CustomRequire: cfg.CustomRequire, 301 CustomBlock: split(cfg.CustomBlock), 302 } 303 304 for _, artifact := range artifacts { 305 sum, err := artifact.Checksum("sha256") 306 if err != nil { 307 return result, err 308 } 309 310 if cfg.URLTemplate == "" { 311 url, err := cl.ReleaseURLTemplate(ctx) 312 if err != nil { 313 if client.IsNotImplementedErr(err) { 314 return result, ErrTokenTypeNotImplementedForBrew{ctx.TokenType} 315 } 316 return result, err 317 } 318 cfg.URLTemplate = url 319 } 320 url, err := tmpl.New(ctx).WithArtifact(artifact, map[string]string{}).Apply(cfg.URLTemplate) 321 if err != nil { 322 return result, err 323 } 324 var down = downloadable{ 325 DownloadURL: url, 326 SHA256: sum, 327 } 328 // TODO: refactor 329 if artifact.Goos == "darwin" { // nolint: nestif 330 if result.MacOS.DownloadURL != "" { 331 return result, ErrMultipleArchivesSameOS 332 } 333 result.MacOS = down 334 } else if artifact.Goos == "linux" { 335 switch artifact.Goarch { 336 case "amd64": 337 if result.LinuxAmd64.DownloadURL != "" { 338 return result, ErrMultipleArchivesSameOS 339 } 340 result.LinuxAmd64 = down 341 case "arm": 342 if result.LinuxArm.DownloadURL != "" { 343 return result, ErrMultipleArchivesSameOS 344 } 345 result.LinuxArm = down 346 case "arm64": 347 if result.LinuxArm64.DownloadURL != "" { 348 return result, ErrMultipleArchivesSameOS 349 } 350 result.LinuxArm64 = down 351 } 352 } 353 } 354 355 return result, nil 356 } 357 358 func split(s string) []string { 359 strings := strings.Split(strings.TrimSpace(s), "\n") 360 if len(strings) == 1 && strings[0] == "" { 361 return []string{} 362 } 363 return strings 364 } 365 366 func formulaNameFor(name string) string { 367 name = strings.ReplaceAll(name, "-", " ") 368 name = strings.ReplaceAll(name, "_", " ") 369 name = strings.ReplaceAll(name, "@", "AT") 370 return strings.ReplaceAll(strings.Title(name), " ", "") 371 }