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  }