github.com/goreleaser/goreleaser@v1.25.1/internal/pipe/brew/brew.go (about) 1 package brew 2 3 import ( 4 "bufio" 5 "bytes" 6 "errors" 7 "fmt" 8 "os" 9 "path" 10 "path/filepath" 11 "reflect" 12 "sort" 13 "strings" 14 "text/template" 15 16 "github.com/caarlos0/log" 17 "github.com/goreleaser/goreleaser/internal/artifact" 18 "github.com/goreleaser/goreleaser/internal/client" 19 "github.com/goreleaser/goreleaser/internal/commitauthor" 20 "github.com/goreleaser/goreleaser/internal/deprecate" 21 "github.com/goreleaser/goreleaser/internal/pipe" 22 "github.com/goreleaser/goreleaser/internal/skips" 23 "github.com/goreleaser/goreleaser/internal/tmpl" 24 "github.com/goreleaser/goreleaser/pkg/config" 25 "github.com/goreleaser/goreleaser/pkg/context" 26 "golang.org/x/text/cases" 27 "golang.org/x/text/language" 28 ) 29 30 const brewConfigExtra = "BrewConfig" 31 32 // ErrMultipleArchivesSameOS happens when the config yields multiple archives 33 // for linux or windows. 34 var ErrMultipleArchivesSameOS = errors.New("one tap can handle only one archive of an OS/Arch combination. Consider using ids in the brew section") 35 36 // ErrNoArchivesFound happens when 0 archives are found. 37 type ErrNoArchivesFound struct { 38 goarm string 39 goamd64 string 40 ids []string 41 } 42 43 func (e ErrNoArchivesFound) Error() string { 44 return fmt.Sprintf("no linux/macos archives found matching goos=[darwin linux] goarch=[amd64 arm64 arm] goamd64=%s goarm=%s ids=%v", e.goamd64, e.goarm, e.ids) 45 } 46 47 // Pipe for brew deployment. 48 type Pipe struct{} 49 50 func (Pipe) String() string { return "homebrew tap formula" } 51 func (Pipe) ContinueOnError() bool { return true } 52 func (Pipe) Skip(ctx *context.Context) bool { 53 return skips.Any(ctx, skips.Homebrew) || len(ctx.Config.Brews) == 0 54 } 55 56 func (Pipe) Default(ctx *context.Context) error { 57 for i := range ctx.Config.Brews { 58 brew := &ctx.Config.Brews[i] 59 60 brew.CommitAuthor = commitauthor.Default(brew.CommitAuthor) 61 62 if brew.CommitMessageTemplate == "" { 63 brew.CommitMessageTemplate = "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 64 } 65 if brew.Name == "" { 66 brew.Name = ctx.Config.ProjectName 67 } 68 if brew.Goarm == "" { 69 brew.Goarm = "6" 70 } 71 if brew.Goamd64 == "" { 72 brew.Goamd64 = "v1" 73 } 74 if brew.Plist != "" { 75 deprecate.Notice(ctx, "brews.plist") 76 } 77 if brew.Folder != "" { 78 deprecate.Notice(ctx, "brews.folder") 79 brew.Directory = brew.Folder 80 } 81 if !reflect.DeepEqual(brew.Tap, config.RepoRef{}) { 82 brew.Repository = brew.Tap 83 deprecate.Notice(ctx, "brews.tap") 84 } 85 } 86 87 return nil 88 } 89 90 func (Pipe) Run(ctx *context.Context) error { 91 cli, err := client.NewReleaseClient(ctx) 92 if err != nil { 93 return err 94 } 95 96 return runAll(ctx, cli) 97 } 98 99 // Publish brew formula. 100 func (Pipe) Publish(ctx *context.Context) error { 101 cli, err := client.New(ctx) 102 if err != nil { 103 return err 104 } 105 return publishAll(ctx, cli) 106 } 107 108 func runAll(ctx *context.Context, cli client.ReleaseURLTemplater) error { 109 for _, brew := range ctx.Config.Brews { 110 err := doRun(ctx, brew, cli) 111 if err != nil { 112 return err 113 } 114 } 115 return nil 116 } 117 118 func publishAll(ctx *context.Context, cli client.Client) error { 119 // even if one of them skips, we run them all, and then show return the skips all at once. 120 // this is needed so we actually create the `dist/foo.rb` file, which is useful for debugging. 121 skips := pipe.SkipMemento{} 122 for _, formula := range ctx.Artifacts.Filter(artifact.ByType(artifact.BrewTap)).List() { 123 err := doPublish(ctx, formula, cli) 124 if err != nil && pipe.IsSkip(err) { 125 skips.Remember(err) 126 continue 127 } 128 if err != nil { 129 return err 130 } 131 } 132 return skips.Evaluate() 133 } 134 135 func doPublish(ctx *context.Context, formula *artifact.Artifact, cl client.Client) error { 136 brew, err := artifact.Extra[config.Homebrew](*formula, brewConfigExtra) 137 if err != nil { 138 return err 139 } 140 141 if strings.TrimSpace(brew.SkipUpload) == "true" { 142 return pipe.Skip("brew.skip_upload is set") 143 } 144 145 if strings.TrimSpace(brew.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" { 146 return pipe.Skip("prerelease detected with 'auto' upload, skipping homebrew publish") 147 } 148 149 repo := client.RepoFromRef(brew.Repository) 150 151 gpath := buildFormulaPath(brew.Directory, formula.Name) 152 153 msg, err := tmpl.New(ctx).Apply(brew.CommitMessageTemplate) 154 if err != nil { 155 return err 156 } 157 158 author, err := commitauthor.Get(ctx, brew.CommitAuthor) 159 if err != nil { 160 return err 161 } 162 163 content, err := os.ReadFile(formula.Path) 164 if err != nil { 165 return err 166 } 167 168 if brew.Repository.Git.URL != "" { 169 return client.NewGitUploadClient(repo.Branch). 170 CreateFile(ctx, author, repo, content, gpath, msg) 171 } 172 173 cl, err = client.NewIfToken(ctx, cl, brew.Repository.Token) 174 if err != nil { 175 return err 176 } 177 178 base := client.Repo{ 179 Name: brew.Repository.PullRequest.Base.Name, 180 Owner: brew.Repository.PullRequest.Base.Owner, 181 Branch: brew.Repository.PullRequest.Base.Branch, 182 } 183 184 // try to sync branch 185 fscli, ok := cl.(client.ForkSyncer) 186 if ok && brew.Repository.PullRequest.Enabled { 187 if err := fscli.SyncFork(ctx, repo, base); err != nil { 188 log.WithError(err).Warn("could not sync fork") 189 } 190 } 191 192 if err := cl.CreateFile(ctx, author, repo, content, gpath, msg); err != nil { 193 return err 194 } 195 196 if !brew.Repository.PullRequest.Enabled { 197 log.Debug("brews.pull_request disabled") 198 return nil 199 } 200 201 log.Info("brews.pull_request enabled, creating a PR") 202 pcl, ok := cl.(client.PullRequestOpener) 203 if !ok { 204 return fmt.Errorf("client does not support pull requests") 205 } 206 207 return pcl.OpenPullRequest(ctx, base, repo, msg, brew.Repository.PullRequest.Draft) 208 } 209 210 func doRun(ctx *context.Context, brew config.Homebrew, cl client.ReleaseURLTemplater) error { 211 if brew.Repository.Name == "" { 212 return pipe.Skip("brew.repository.name is not set") 213 } 214 215 filters := []artifact.Filter{ 216 artifact.Or( 217 artifact.ByGoos("darwin"), 218 artifact.ByGoos("linux"), 219 ), 220 artifact.Or( 221 artifact.And( 222 artifact.ByGoarch("amd64"), 223 artifact.ByGoamd64(brew.Goamd64), 224 ), 225 artifact.ByGoarch("arm64"), 226 artifact.ByGoarch("all"), 227 artifact.And( 228 artifact.ByGoarch("arm"), 229 artifact.ByGoarm(brew.Goarm), 230 ), 231 ), 232 artifact.Or( 233 artifact.And( 234 artifact.ByFormats("zip", "tar.gz", "tar.xz"), 235 artifact.ByType(artifact.UploadableArchive), 236 ), 237 artifact.ByType(artifact.UploadableBinary), 238 ), 239 artifact.OnlyReplacingUnibins, 240 } 241 if len(brew.IDs) > 0 { 242 filters = append(filters, artifact.ByIDs(brew.IDs...)) 243 } 244 245 archives := ctx.Artifacts.Filter(artifact.And(filters...)).List() 246 if len(archives) == 0 { 247 return ErrNoArchivesFound{ 248 goamd64: brew.Goamd64, 249 goarm: brew.Goarm, 250 ids: brew.IDs, 251 } 252 } 253 254 name, err := tmpl.New(ctx).Apply(brew.Name) 255 if err != nil { 256 return err 257 } 258 brew.Name = name 259 260 ref, err := client.TemplateRef(tmpl.New(ctx).Apply, brew.Repository) 261 if err != nil { 262 return err 263 } 264 brew.Repository = ref 265 266 skipUpload, err := tmpl.New(ctx).Apply(brew.SkipUpload) 267 if err != nil { 268 return err 269 } 270 brew.SkipUpload = skipUpload 271 272 content, err := buildFormula(ctx, brew, cl, archives) 273 if err != nil { 274 return err 275 } 276 277 filename := brew.Name + ".rb" 278 path := filepath.Join(ctx.Config.Dist, "homebrew", brew.Directory, filename) 279 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 280 return err 281 } 282 283 log.WithField("formula", path).Info("writing") 284 if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint: gosec 285 return fmt.Errorf("failed to write brew formula: %w", err) 286 } 287 288 ctx.Artifacts.Add(&artifact.Artifact{ 289 Name: filename, 290 Path: path, 291 Type: artifact.BrewTap, 292 Extra: map[string]interface{}{ 293 brewConfigExtra: brew, 294 }, 295 }) 296 297 return nil 298 } 299 300 func buildFormulaPath(folder, filename string) string { 301 return path.Join(folder, filename) 302 } 303 304 func buildFormula(ctx *context.Context, brew config.Homebrew, client client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (string, error) { 305 data, err := dataFor(ctx, brew, client, artifacts) 306 if err != nil { 307 return "", err 308 } 309 return doBuildFormula(ctx, data) 310 } 311 312 func doBuildFormula(ctx *context.Context, data templateData) (string, error) { 313 t := template.New("cask.rb") 314 var err error 315 t, err = t.Funcs(map[string]any{ 316 "include": func(name string, data interface{}) (string, error) { 317 buf := bytes.NewBuffer(nil) 318 if err := t.ExecuteTemplate(buf, name, data); err != nil { 319 return "", err 320 } 321 return buf.String(), nil 322 }, 323 "indent": func(spaces int, v string) string { 324 pad := strings.Repeat(" ", spaces) 325 return pad + strings.ReplaceAll(v, "\n", "\n"+pad) 326 }, 327 "join": func(in []string) string { 328 items := make([]string, 0, len(in)) 329 for _, i := range in { 330 items = append(items, fmt.Sprintf(`"%s"`, i)) 331 } 332 return strings.Join(items, ",\n") 333 }, 334 }).ParseFS(formulaTemplate, "templates/*.rb") 335 if err != nil { 336 return "", err 337 } 338 var out bytes.Buffer 339 if err := t.Execute(&out, data); err != nil { 340 return "", err 341 } 342 343 content, err := tmpl.New(ctx).Apply(out.String()) 344 if err != nil { 345 return "", err 346 } 347 out.Reset() 348 349 // Sanitize the template output and get rid of trailing whitespace. 350 var ( 351 r = strings.NewReader(content) 352 s = bufio.NewScanner(r) 353 ) 354 for s.Scan() { 355 l := strings.TrimRight(s.Text(), " ") 356 _, _ = out.WriteString(l) 357 _ = out.WriteByte('\n') 358 } 359 if err := s.Err(); err != nil { 360 return "", err 361 } 362 363 return out.String(), nil 364 } 365 366 func installs(ctx *context.Context, cfg config.Homebrew, art *artifact.Artifact) ([]string, error) { 367 tpl := tmpl.New(ctx).WithArtifact(art) 368 369 extraInstall, err := tpl.Apply(cfg.ExtraInstall) 370 if err != nil { 371 return nil, err 372 } 373 374 install, err := tpl.Apply(cfg.Install) 375 if err != nil { 376 return nil, err 377 } 378 if install != "" { 379 return append(split(install), split(extraInstall)...), nil 380 } 381 382 installMap := map[string]bool{} 383 switch art.Type { 384 case artifact.UploadableBinary: 385 name := art.Name 386 bin := artifact.ExtraOr(*art, artifact.ExtraBinary, art.Name) 387 installMap[fmt.Sprintf("bin.install %q => %q", name, bin)] = true 388 case artifact.UploadableArchive: 389 for _, bin := range artifact.ExtraOr(*art, artifact.ExtraBinaries, []string{}) { 390 installMap[fmt.Sprintf("bin.install %q", bin)] = true 391 } 392 } 393 394 result := keys(installMap) 395 sort.Strings(result) 396 log.WithField("install", result).Info("guessing install") 397 398 return append(result, split(extraInstall)...), nil 399 } 400 401 func keys(m map[string]bool) []string { 402 keys := make([]string, 0, len(m)) 403 for k := range m { 404 keys = append(keys, k) 405 } 406 return keys 407 } 408 409 func dataFor(ctx *context.Context, cfg config.Homebrew, cl client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (templateData, error) { 410 sort.Slice(cfg.Dependencies, func(i, j int) bool { 411 return cfg.Dependencies[i].Name < cfg.Dependencies[j].Name 412 }) 413 result := templateData{ 414 Name: formulaNameFor(cfg.Name), 415 Desc: cfg.Description, 416 Homepage: cfg.Homepage, 417 Version: ctx.Version, 418 License: cfg.License, 419 Caveats: split(cfg.Caveats), 420 Dependencies: cfg.Dependencies, 421 Conflicts: cfg.Conflicts, 422 Plist: cfg.Plist, 423 Service: split(cfg.Service), 424 PostInstall: split(cfg.PostInstall), 425 Tests: split(cfg.Test), 426 CustomRequire: cfg.CustomRequire, 427 CustomBlock: split(cfg.CustomBlock), 428 } 429 430 counts := map[string]int{} 431 for _, art := range artifacts { 432 sum, err := art.Checksum("sha256") 433 if err != nil { 434 return result, err 435 } 436 437 if cfg.URLTemplate == "" { 438 url, err := cl.ReleaseURLTemplate(ctx) 439 if err != nil { 440 return result, err 441 } 442 cfg.URLTemplate = url 443 } 444 445 url, err := tmpl.New(ctx).WithArtifact(art).Apply(cfg.URLTemplate) 446 if err != nil { 447 return result, err 448 } 449 450 install, err := installs(ctx, cfg, art) 451 if err != nil { 452 return result, err 453 } 454 455 pkg := releasePackage{ 456 DownloadURL: url, 457 SHA256: sum, 458 OS: art.Goos, 459 Arch: art.Goarch, 460 DownloadStrategy: cfg.DownloadStrategy, 461 Headers: cfg.URLHeaders, 462 Install: install, 463 } 464 465 counts[pkg.OS+pkg.Arch]++ 466 467 switch pkg.OS { 468 case "darwin": 469 result.MacOSPackages = append(result.MacOSPackages, pkg) 470 case "linux": 471 result.LinuxPackages = append(result.LinuxPackages, pkg) 472 } 473 } 474 475 for _, v := range counts { 476 if v > 1 { 477 return result, ErrMultipleArchivesSameOS 478 } 479 } 480 481 if len(result.MacOSPackages) == 1 && result.MacOSPackages[0].Arch == "amd64" { 482 result.HasOnlyAmd64MacOsPkg = true 483 } 484 485 sort.Slice(result.LinuxPackages, lessFnFor(result.LinuxPackages)) 486 sort.Slice(result.MacOSPackages, lessFnFor(result.MacOSPackages)) 487 return result, nil 488 } 489 490 func lessFnFor(list []releasePackage) func(i, j int) bool { 491 return func(i, j int) bool { return list[i].Arch < list[j].Arch } 492 } 493 494 func split(s string) []string { 495 strings := strings.Split(strings.TrimSpace(s), "\n") 496 if len(strings) == 1 && strings[0] == "" { 497 return []string{} 498 } 499 return strings 500 } 501 502 // formulaNameFor transforms the formula name into a form 503 // that more resembles a valid Ruby class name 504 // e.g. foo_bar@v6.0.0-rc is turned into FooBarATv6_0_0RC 505 // The order of these replacements is important 506 func formulaNameFor(name string) string { 507 name = strings.ReplaceAll(name, "-", " ") 508 name = strings.ReplaceAll(name, "_", " ") 509 name = strings.ReplaceAll(name, ".", "") 510 name = cases.Title(language.English).String(name) 511 name = strings.ReplaceAll(name, " ", "") 512 return strings.ReplaceAll(name, "@", "AT") 513 }