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