github.com/amane3/goreleaser@v0.182.0/internal/pipe/brew/brew.go (about)

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