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  }