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  }