1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2025-01-08 03:31:59 +02:00
goreleaser/internal/pipe/brew/brew.go
Ryan Currah f734b503d4
feat(pipe/brew): install amd64 binaries when no arm64 binaries present (#2939)
Currently on a M1 macbook when adding a tap with formulas that only support amd64 it fails to add the tap. This prevents new arm64 users from using the tap. By allowing arm64 users to install an amd64 binary if no arm64 binary is avialable will atleast allow the user to use the tap.
2022-02-25 21:57:46 -03:00

409 lines
9.9 KiB
Go

package brew
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"text/template"
"github.com/apex/log"
"github.com/goreleaser/goreleaser/internal/artifact"
"github.com/goreleaser/goreleaser/internal/client"
"github.com/goreleaser/goreleaser/internal/commitauthor"
"github.com/goreleaser/goreleaser/internal/pipe"
"github.com/goreleaser/goreleaser/internal/tmpl"
"github.com/goreleaser/goreleaser/pkg/config"
"github.com/goreleaser/goreleaser/pkg/context"
)
const brewConfigExtra = "BrewConfig"
var (
// ErrNoArchivesFound happens when 0 archives are found.
ErrNoArchivesFound = errors.New("no linux/macos archives found")
// ErrMultipleArchivesSameOS happens when the config yields multiple archives
// for linux or windows.
ErrMultipleArchivesSameOS = errors.New("one tap can handle only archive of an OS/Arch combination. Consider using ids in the brew section")
)
// Pipe for brew deployment.
type Pipe struct{}
func (Pipe) String() string { return "homebrew tap formula" }
func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Brews) == 0 }
func (Pipe) Default(ctx *context.Context) error {
for i := range ctx.Config.Brews {
brew := &ctx.Config.Brews[i]
brew.CommitAuthor = commitauthor.Default(brew.CommitAuthor)
if brew.CommitMessageTemplate == "" {
brew.CommitMessageTemplate = "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
}
if brew.Name == "" {
brew.Name = ctx.Config.ProjectName
}
if brew.Goarm == "" {
brew.Goarm = "6"
}
}
return nil
}
func (Pipe) Run(ctx *context.Context) error {
cli, err := client.New(ctx)
if err != nil {
return err
}
return runAll(ctx, cli)
}
// Publish brew formula.
func (Pipe) Publish(ctx *context.Context) error {
cli, err := client.New(ctx)
if err != nil {
return err
}
return publishAll(ctx, cli)
}
func runAll(ctx *context.Context, cli client.Client) error {
for _, brew := range ctx.Config.Brews {
err := doRun(ctx, brew, cli)
if err != nil {
return err
}
}
return nil
}
func publishAll(ctx *context.Context, cli client.Client) error {
// even if one of them skips, we run them all, and then show return the skips all at once.
// this is needed so we actually create the `dist/foo.rb` file, which is useful for debugging.
skips := pipe.SkipMemento{}
for _, formula := range ctx.Artifacts.Filter(artifact.ByType(artifact.BrewTap)).List() {
err := doPublish(ctx, formula, cli)
if err != nil && pipe.IsSkip(err) {
skips.Remember(err)
continue
}
if err != nil {
return err
}
}
return skips.Evaluate()
}
func doPublish(ctx *context.Context, formula *artifact.Artifact, cl client.Client) error {
brew := formula.Extra[brewConfigExtra].(config.Homebrew)
var err error
cl, err = client.NewIfToken(ctx, cl, brew.Tap.Token)
if err != nil {
return err
}
if strings.TrimSpace(brew.SkipUpload) == "true" {
return pipe.Skip("brew.skip_upload is set")
}
if strings.TrimSpace(brew.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
return pipe.Skip("prerelease detected with 'auto' upload, skipping homebrew publish")
}
repo := client.RepoFromRef(brew.Tap)
gpath := buildFormulaPath(brew.Folder, formula.Name)
log.WithField("formula", gpath).
WithField("repo", repo.String()).
Info("pushing")
msg, err := tmpl.New(ctx).Apply(brew.CommitMessageTemplate)
if err != nil {
return err
}
author, err := commitauthor.Get(ctx, brew.CommitAuthor)
if err != nil {
return err
}
content, err := os.ReadFile(formula.Path)
if err != nil {
return err
}
return cl.CreateFile(ctx, author, repo, content, gpath, msg)
}
func doRun(ctx *context.Context, brew config.Homebrew, cl client.Client) error {
if brew.Tap.Name == "" {
return pipe.Skip("brew tap name is not set")
}
// TODO: properly cover this with tests
filters := []artifact.Filter{
artifact.Or(
artifact.ByGoos("darwin"),
artifact.ByGoos("linux"),
),
artifact.Or(
artifact.ByGoarch("amd64"),
artifact.ByGoarch("arm64"),
artifact.ByGoarch("all"),
artifact.And(
artifact.ByGoarch("arm"),
artifact.ByGoarm(brew.Goarm),
),
),
artifact.Or(
artifact.And(
artifact.ByFormats("zip", "tar.gz"),
artifact.ByType(artifact.UploadableArchive),
),
artifact.ByType(artifact.UploadableBinary),
),
artifact.OnlyReplacingUnibins,
}
if len(brew.IDs) > 0 {
filters = append(filters, artifact.ByIDs(brew.IDs...))
}
archives := ctx.Artifacts.Filter(artifact.And(filters...)).List()
if len(archives) == 0 {
return ErrNoArchivesFound
}
name, err := tmpl.New(ctx).Apply(brew.Name)
if err != nil {
return err
}
brew.Name = name
tapOwner, err := tmpl.New(ctx).Apply(brew.Tap.Owner)
if err != nil {
return err
}
brew.Tap.Owner = tapOwner
tapName, err := tmpl.New(ctx).Apply(brew.Tap.Name)
if err != nil {
return err
}
brew.Tap.Name = tapName
skipUpload, err := tmpl.New(ctx).Apply(brew.SkipUpload)
if err != nil {
return err
}
brew.SkipUpload = skipUpload
content, err := buildFormula(ctx, brew, cl, archives)
if err != nil {
return err
}
filename := brew.Name + ".rb"
path := filepath.Join(ctx.Config.Dist, filename)
log.WithField("formula", path).Info("writing")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint: gosec
return fmt.Errorf("failed to write brew formula: %w", err)
}
ctx.Artifacts.Add(&artifact.Artifact{
Name: filename,
Path: path,
Type: artifact.BrewTap,
Extra: map[string]interface{}{
brewConfigExtra: brew,
},
})
return nil
}
func buildFormulaPath(folder, filename string) string {
return path.Join(folder, filename)
}
func buildFormula(ctx *context.Context, brew config.Homebrew, client client.Client, artifacts []*artifact.Artifact) (string, error) {
data, err := dataFor(ctx, brew, client, artifacts)
if err != nil {
return "", err
}
return doBuildFormula(ctx, data)
}
func doBuildFormula(ctx *context.Context, data templateData) (string, error) {
t, err := template.
New(data.Name).
Funcs(template.FuncMap{
"join": strings.Join,
}).
Parse(formulaTemplate)
if err != nil {
return "", err
}
var out bytes.Buffer
if err := t.Execute(&out, data); err != nil {
return "", err
}
content, err := tmpl.New(ctx).Apply(out.String())
if err != nil {
return "", err
}
out.Reset()
// Sanitize the template output and get rid of trailing whitespace.
var (
r = strings.NewReader(content)
s = bufio.NewScanner(r)
)
for s.Scan() {
l := strings.TrimRight(s.Text(), " ")
_, _ = out.WriteString(l)
_ = out.WriteByte('\n')
}
if err := s.Err(); err != nil {
return "", err
}
return out.String(), nil
}
func installs(cfg config.Homebrew, art *artifact.Artifact) []string {
if cfg.Install != "" {
return split(cfg.Install)
}
install := map[string]bool{}
switch art.Type {
case artifact.UploadableBinary:
name := art.Name
bin := art.ExtraOr(artifact.ExtraBinary, art.Name).(string)
install[fmt.Sprintf("bin.install %q => %q", name, bin)] = true
case artifact.UploadableArchive:
for _, bin := range art.ExtraOr(artifact.ExtraBinaries, []string{}).([]string) {
install[fmt.Sprintf("bin.install %q", bin)] = true
}
}
result := keys(install)
sort.Strings(result)
log.Warnf("guessing install to be %q", strings.Join(result, ", "))
return result
}
func keys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
func dataFor(ctx *context.Context, cfg config.Homebrew, cl client.Client, artifacts []*artifact.Artifact) (templateData, error) {
result := templateData{
Name: formulaNameFor(cfg.Name),
Desc: cfg.Description,
Homepage: cfg.Homepage,
Version: ctx.Version,
License: cfg.License,
Caveats: split(cfg.Caveats),
Dependencies: cfg.Dependencies,
Conflicts: cfg.Conflicts,
Plist: cfg.Plist,
PostInstall: cfg.PostInstall,
Tests: split(cfg.Test),
CustomRequire: cfg.CustomRequire,
CustomBlock: split(cfg.CustomBlock),
}
counts := map[string]int{}
for _, art := range artifacts {
sum, err := art.Checksum("sha256")
if err != nil {
return result, err
}
if cfg.URLTemplate == "" {
url, err := cl.ReleaseURLTemplate(ctx)
if err != nil {
return result, err
}
cfg.URLTemplate = url
}
url, err := tmpl.New(ctx).WithArtifact(art, map[string]string{}).Apply(cfg.URLTemplate)
if err != nil {
return result, err
}
pkg := releasePackage{
DownloadURL: url,
SHA256: sum,
OS: art.Goos,
Arch: art.Goarch,
DownloadStrategy: cfg.DownloadStrategy,
Install: installs(cfg, art),
}
counts[pkg.OS+pkg.Arch]++
switch pkg.OS {
case "darwin":
result.MacOSPackages = append(result.MacOSPackages, pkg)
case "linux":
result.LinuxPackages = append(result.LinuxPackages, pkg)
}
}
for _, v := range counts {
if v > 1 {
return result, ErrMultipleArchivesSameOS
}
}
if len(result.MacOSPackages) == 1 && result.MacOSPackages[0].Arch == "amd64" {
result.HasOnlyAmd64MacOsPkg = true
}
sort.Slice(result.LinuxPackages, lessFnFor(result.LinuxPackages))
sort.Slice(result.MacOSPackages, lessFnFor(result.MacOSPackages))
return result, nil
}
func lessFnFor(list []releasePackage) func(i, j int) bool {
return func(i, j int) bool { return list[i].OS > list[j].OS && list[i].Arch > list[j].Arch }
}
func split(s string) []string {
strings := strings.Split(strings.TrimSpace(s), "\n")
if len(strings) == 1 && strings[0] == "" {
return []string{}
}
return strings
}
// formulaNameFor transforms the formula name into a form
// that more resembles a valid Ruby class name
// e.g. foo_bar@v6.0.0-rc is turned into FooBarATv6_0_0RC
// The order of these replacements is important
func formulaNameFor(name string) string {
name = strings.ReplaceAll(name, "-", " ")
name = strings.ReplaceAll(name, "_", " ")
name = strings.ReplaceAll(name, ".", "_")
name = strings.ReplaceAll(name, "@", "AT")
return strings.ReplaceAll(strings.Title(name), " ", "")
}