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