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