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  }