github.com/windmeup/goreleaser@v1.21.95/internal/pipe/scoop/scoop.go (about)

     1  // Package scoop provides a Pipe that generates a scoop.sh App Manifest and pushes it to a bucket.
     2  package scoop
     3  
     4  import (
     5  	"bytes"
     6  	"encoding/json"
     7  	"fmt"
     8  	"os"
     9  	"path"
    10  	"path/filepath"
    11  	"reflect"
    12  	"strings"
    13  
    14  	"github.com/caarlos0/log"
    15  	"github.com/windmeup/goreleaser/internal/artifact"
    16  	"github.com/windmeup/goreleaser/internal/client"
    17  	"github.com/windmeup/goreleaser/internal/commitauthor"
    18  	"github.com/windmeup/goreleaser/internal/deprecate"
    19  	"github.com/windmeup/goreleaser/internal/pipe"
    20  	"github.com/windmeup/goreleaser/internal/skips"
    21  	"github.com/windmeup/goreleaser/internal/tmpl"
    22  	"github.com/windmeup/goreleaser/pkg/config"
    23  	"github.com/windmeup/goreleaser/pkg/context"
    24  )
    25  
    26  // ErrIncorrectArchiveCount happens when a given filter evaluates 0 or more
    27  // than 1 archives.
    28  type ErrIncorrectArchiveCount struct {
    29  	goamd64  string
    30  	ids      []string
    31  	archives []*artifact.Artifact
    32  }
    33  
    34  func (e ErrIncorrectArchiveCount) Error() string {
    35  	b := strings.Builder{}
    36  
    37  	_, _ = b.WriteString("scoop requires a single windows archive, ")
    38  	if len(e.archives) == 0 {
    39  		_, _ = b.WriteString("but no archives ")
    40  	} else {
    41  		_, _ = b.WriteString(fmt.Sprintf("but found %d archives ", len(e.archives)))
    42  	}
    43  
    44  	_, _ = b.WriteString(fmt.Sprintf("matching the given filters: goos=windows goarch=[386 amd64 arm64] goamd64=%s ids=%s", e.goamd64, e.ids))
    45  
    46  	if len(e.archives) > 0 {
    47  		names := make([]string, 0, len(e.archives))
    48  		for _, a := range e.archives {
    49  			names = append(names, a.Name)
    50  		}
    51  		_, _ = b.WriteString(fmt.Sprintf(": %s", names))
    52  	}
    53  
    54  	_, _ = b.WriteString("\nLearn more at https://goreleaser.com/errors/scoop-archive\n")
    55  	return b.String()
    56  }
    57  
    58  const scoopConfigExtra = "ScoopConfig"
    59  
    60  // Pipe that builds and publishes scoop manifests.
    61  type Pipe struct{}
    62  
    63  func (Pipe) String() string        { return "scoop manifests" }
    64  func (Pipe) ContinueOnError() bool { return true }
    65  func (Pipe) Skip(ctx *context.Context) bool {
    66  	return skips.Any(ctx, skips.Scoop) || (ctx.Config.Scoop.Repository.Name == "" && len(ctx.Config.Scoops) == 0)
    67  }
    68  
    69  // Run creates the scoop manifest locally.
    70  func (Pipe) Run(ctx *context.Context) error {
    71  	cli, err := client.NewReleaseClient(ctx)
    72  	if err != nil {
    73  		return err
    74  	}
    75  	return runAll(ctx, cli)
    76  }
    77  
    78  // Publish scoop manifest.
    79  func (Pipe) Publish(ctx *context.Context) error {
    80  	client, err := client.New(ctx)
    81  	if err != nil {
    82  		return err
    83  	}
    84  	return publishAll(ctx, client)
    85  }
    86  
    87  // Default sets the pipe defaults.
    88  func (Pipe) Default(ctx *context.Context) error {
    89  	if !reflect.DeepEqual(ctx.Config.Scoop.Bucket, config.RepoRef{}) ||
    90  		!reflect.DeepEqual(ctx.Config.Scoop.Repository, config.RepoRef{}) {
    91  		deprecate.Notice(ctx, "scoop")
    92  		ctx.Config.Scoops = append(ctx.Config.Scoops, ctx.Config.Scoop)
    93  	}
    94  
    95  	for i := range ctx.Config.Scoops {
    96  		scoop := &ctx.Config.Scoops[i]
    97  		if scoop.Name == "" {
    98  			scoop.Name = ctx.Config.ProjectName
    99  		}
   100  		scoop.CommitAuthor = commitauthor.Default(scoop.CommitAuthor)
   101  		if scoop.CommitMessageTemplate == "" {
   102  			scoop.CommitMessageTemplate = "Scoop update for {{ .ProjectName }} version {{ .Tag }}"
   103  		}
   104  		if scoop.Goamd64 == "" {
   105  			scoop.Goamd64 = "v1"
   106  		}
   107  		if !reflect.DeepEqual(scoop.Bucket, config.RepoRef{}) {
   108  			scoop.Repository = scoop.Bucket
   109  			deprecate.Notice(ctx, "scoops.bucket")
   110  		}
   111  	}
   112  	return nil
   113  }
   114  
   115  func runAll(ctx *context.Context, cl client.ReleaseURLTemplater) error {
   116  	for _, scoop := range ctx.Config.Scoops {
   117  		err := doRun(ctx, scoop, cl)
   118  		if err != nil {
   119  			return err
   120  		}
   121  	}
   122  	return nil
   123  }
   124  
   125  func doRun(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater) error {
   126  	filters := []artifact.Filter{
   127  		artifact.ByGoos("windows"),
   128  		artifact.ByType(artifact.UploadableArchive),
   129  		artifact.Or(
   130  			artifact.And(
   131  				artifact.ByGoarch("amd64"),
   132  				artifact.ByGoamd64(scoop.Goamd64),
   133  			),
   134  			artifact.ByGoarch("arm64"),
   135  			artifact.ByGoarch("386"),
   136  		),
   137  	}
   138  
   139  	if len(scoop.IDs) > 0 {
   140  		filters = append(filters, artifact.ByIDs(scoop.IDs...))
   141  	}
   142  
   143  	filtered := ctx.Artifacts.Filter(artifact.And(filters...))
   144  	archives := filtered.List()
   145  	for _, platArchives := range filtered.GroupByPlatform() {
   146  		// there might be multiple archives, but only of for each platform
   147  		if len(platArchives) != 1 {
   148  			return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives}
   149  		}
   150  	}
   151  	// handle no archives found whatsoever
   152  	if len(archives) == 0 {
   153  		return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives}
   154  	}
   155  
   156  	tp := tmpl.New(ctx)
   157  
   158  	if err := tp.ApplyAll(
   159  		&scoop.Name,
   160  		&scoop.Description,
   161  		&scoop.Homepage,
   162  		&scoop.SkipUpload,
   163  	); err != nil {
   164  		return err
   165  	}
   166  
   167  	ref, err := client.TemplateRef(tmpl.New(ctx).Apply, scoop.Repository)
   168  	if err != nil {
   169  		return err
   170  	}
   171  	scoop.Repository = ref
   172  
   173  	data, err := dataFor(ctx, scoop, cl, archives)
   174  	if err != nil {
   175  		return err
   176  	}
   177  	content, err := doBuildManifest(data)
   178  	if err != nil {
   179  		return err
   180  	}
   181  
   182  	filename := scoop.Name + ".json"
   183  	path := filepath.Join(ctx.Config.Dist, "scoop", scoop.Folder, filename)
   184  	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
   185  		return err
   186  	}
   187  	log.WithField("manifest", path).Info("writing")
   188  	if err := os.WriteFile(path, content.Bytes(), 0o644); err != nil {
   189  		return fmt.Errorf("failed to write scoop manifest: %w", err)
   190  	}
   191  
   192  	ctx.Artifacts.Add(&artifact.Artifact{
   193  		Name: filename,
   194  		Path: path,
   195  		Type: artifact.ScoopManifest,
   196  		Extra: map[string]interface{}{
   197  			scoopConfigExtra: scoop,
   198  		},
   199  	})
   200  	return nil
   201  }
   202  
   203  func publishAll(ctx *context.Context, cli client.Client) error {
   204  	// even if one of them skips, we run them all, and then show return the
   205  	// skips all at once. this is needed so we actually create the
   206  	// `dist/foo.json` file, which is useful for debugging.
   207  	skips := pipe.SkipMemento{}
   208  	for _, manifest := range ctx.Artifacts.Filter(artifact.ByType(artifact.ScoopManifest)).List() {
   209  		err := doPublish(ctx, manifest, cli)
   210  		if err != nil && pipe.IsSkip(err) {
   211  			skips.Remember(err)
   212  			continue
   213  		}
   214  		if err != nil {
   215  			return err
   216  		}
   217  	}
   218  	return skips.Evaluate()
   219  }
   220  
   221  func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Client) error {
   222  	scoop, err := artifact.Extra[config.Scoop](*manifest, scoopConfigExtra)
   223  	if err != nil {
   224  		return err
   225  	}
   226  
   227  	if strings.TrimSpace(scoop.SkipUpload) == "true" {
   228  		return pipe.Skip("scoop.skip_upload is true")
   229  	}
   230  	if strings.TrimSpace(scoop.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
   231  		return pipe.Skip("release is prerelease")
   232  	}
   233  
   234  	commitMessage, err := tmpl.New(ctx).Apply(scoop.CommitMessageTemplate)
   235  	if err != nil {
   236  		return err
   237  	}
   238  
   239  	author, err := commitauthor.Get(ctx, scoop.CommitAuthor)
   240  	if err != nil {
   241  		return err
   242  	}
   243  
   244  	content, err := os.ReadFile(manifest.Path)
   245  	if err != nil {
   246  		return err
   247  	}
   248  
   249  	repo := client.RepoFromRef(scoop.Repository)
   250  	gpath := path.Join(scoop.Folder, manifest.Name)
   251  
   252  	if scoop.Repository.Git.URL != "" {
   253  		return client.NewGitUploadClient(repo.Branch).
   254  			CreateFile(ctx, author, repo, content, gpath, commitMessage)
   255  	}
   256  
   257  	cl, err = client.NewIfToken(ctx, cl, scoop.Repository.Token)
   258  	if err != nil {
   259  		return err
   260  	}
   261  
   262  	if !scoop.Repository.PullRequest.Enabled {
   263  		return cl.CreateFile(ctx, author, repo, content, gpath, commitMessage)
   264  	}
   265  
   266  	log.Info("brews.pull_request enabled, creating a PR")
   267  	pcl, ok := cl.(client.PullRequestOpener)
   268  	if !ok {
   269  		return fmt.Errorf("client does not support pull requests")
   270  	}
   271  
   272  	if err := cl.CreateFile(ctx, author, repo, content, gpath, commitMessage); err != nil {
   273  		return err
   274  	}
   275  
   276  	return pcl.OpenPullRequest(ctx, client.Repo{
   277  		Name:   scoop.Repository.PullRequest.Base.Name,
   278  		Owner:  scoop.Repository.PullRequest.Base.Owner,
   279  		Branch: scoop.Repository.PullRequest.Base.Branch,
   280  	}, repo, commitMessage, scoop.Repository.PullRequest.Draft)
   281  }
   282  
   283  // Manifest represents a scoop.sh App Manifest.
   284  // more info: https://github.com/lukesampson/scoop/wiki/App-Manifests
   285  type Manifest struct {
   286  	Version      string              `json:"version"`                // The version of the app that this manifest installs.
   287  	Architecture map[string]Resource `json:"architecture"`           // `architecture`: If the app has 32- and 64-bit versions, architecture can be used to wrap the differences.
   288  	Homepage     string              `json:"homepage,omitempty"`     // `homepage`: The home page for the program.
   289  	License      string              `json:"license,omitempty"`      // `license`: The software license for the program. For well-known licenses, this will be a string like "MIT" or "GPL2". For custom licenses, this should be the URL of the license.
   290  	Description  string              `json:"description,omitempty"`  // Description of the app
   291  	Persist      []string            `json:"persist,omitempty"`      // Persist data between updates
   292  	PreInstall   []string            `json:"pre_install,omitempty"`  // An array of strings, of the commands to be executed before an application is installed.
   293  	PostInstall  []string            `json:"post_install,omitempty"` // An array of strings, of the commands to be executed after an application is installed.
   294  	Depends      []string            `json:"depends,omitempty"`      // A string or an array of strings.
   295  	Shortcuts    [][]string          `json:"shortcuts,omitempty"`    // A two-dimensional array of string, specifies the shortcut values to make available in the startmenu.
   296  }
   297  
   298  // Resource represents a combination of a url and a binary name for an architecture.
   299  type Resource struct {
   300  	URL  string   `json:"url"`  // URL to the archive
   301  	Bin  []string `json:"bin"`  // name of binary inside the archive
   302  	Hash string   `json:"hash"` // the archive checksum
   303  }
   304  
   305  func doBuildManifest(manifest Manifest) (bytes.Buffer, error) {
   306  	var result bytes.Buffer
   307  	data, err := json.MarshalIndent(manifest, "", "    ")
   308  	if err != nil {
   309  		return result, err
   310  	}
   311  	_, err = result.Write(data)
   312  	return result, err
   313  }
   314  
   315  func dataFor(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (Manifest, error) {
   316  	manifest := Manifest{
   317  		Version:      ctx.Version,
   318  		Architecture: map[string]Resource{},
   319  		Homepage:     scoop.Homepage,
   320  		License:      scoop.License,
   321  		Description:  scoop.Description,
   322  		Persist:      scoop.Persist,
   323  		PreInstall:   scoop.PreInstall,
   324  		PostInstall:  scoop.PostInstall,
   325  		Depends:      scoop.Depends,
   326  		Shortcuts:    scoop.Shortcuts,
   327  	}
   328  
   329  	if scoop.URLTemplate == "" {
   330  		url, err := cl.ReleaseURLTemplate(ctx)
   331  		if err != nil {
   332  			return manifest, err
   333  		}
   334  		scoop.URLTemplate = url
   335  	}
   336  
   337  	for _, artifact := range artifacts {
   338  		if artifact.Goos != "windows" {
   339  			continue
   340  		}
   341  
   342  		var arch string
   343  		switch artifact.Goarch {
   344  		case "386":
   345  			arch = "32bit"
   346  		case "amd64":
   347  			arch = "64bit"
   348  		case "arm64":
   349  			arch = "arm64"
   350  		default:
   351  			continue
   352  		}
   353  
   354  		url, err := tmpl.New(ctx).WithArtifact(artifact).Apply(scoop.URLTemplate)
   355  		if err != nil {
   356  			return manifest, err
   357  		}
   358  
   359  		sum, err := artifact.Checksum("sha256")
   360  		if err != nil {
   361  			return manifest, err
   362  		}
   363  
   364  		log.
   365  			WithField("artifactExtras", artifact.Extra).
   366  			WithField("fromURLTemplate", scoop.URLTemplate).
   367  			WithField("templatedBrewURL", url).
   368  			WithField("sum", sum).
   369  			Debug("scoop url templating")
   370  
   371  		binaries, err := binaries(*artifact)
   372  		if err != nil {
   373  			return manifest, err
   374  		}
   375  
   376  		manifest.Architecture[arch] = Resource{
   377  			URL:  url,
   378  			Bin:  binaries,
   379  			Hash: sum,
   380  		}
   381  	}
   382  
   383  	return manifest, nil
   384  }
   385  
   386  func binaries(a artifact.Artifact) ([]string, error) {
   387  	// nolint: prealloc
   388  	var result []string
   389  	wrap := artifact.ExtraOr(a, artifact.ExtraWrappedIn, "")
   390  	bins, err := artifact.Extra[[]string](a, artifact.ExtraBinaries)
   391  	if err != nil {
   392  		return nil, err
   393  	}
   394  	for _, b := range bins {
   395  		result = append(result, filepath.Join(wrap, b))
   396  	}
   397  	return result, nil
   398  }