github.com/goreleaser/goreleaser@v1.25.1/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/goreleaser/goreleaser/internal/artifact"
    16  	"github.com/goreleaser/goreleaser/internal/client"
    17  	"github.com/goreleaser/goreleaser/internal/commitauthor"
    18  	"github.com/goreleaser/goreleaser/internal/deprecate"
    19  	"github.com/goreleaser/goreleaser/internal/pipe"
    20  	"github.com/goreleaser/goreleaser/internal/skips"
    21  	"github.com/goreleaser/goreleaser/internal/tmpl"
    22  	"github.com/goreleaser/goreleaser/pkg/config"
    23  	"github.com/goreleaser/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  		if scoop.Folder != "" {
   101  			deprecate.Notice(ctx, "scoops.folder")
   102  			scoop.Directory = scoop.Folder
   103  		}
   104  		scoop.CommitAuthor = commitauthor.Default(scoop.CommitAuthor)
   105  		if scoop.CommitMessageTemplate == "" {
   106  			scoop.CommitMessageTemplate = "Scoop update for {{ .ProjectName }} version {{ .Tag }}"
   107  		}
   108  		if scoop.Goamd64 == "" {
   109  			scoop.Goamd64 = "v1"
   110  		}
   111  		if !reflect.DeepEqual(scoop.Bucket, config.RepoRef{}) {
   112  			scoop.Repository = scoop.Bucket
   113  			deprecate.Notice(ctx, "scoops.bucket")
   114  		}
   115  	}
   116  	return nil
   117  }
   118  
   119  func runAll(ctx *context.Context, cl client.ReleaseURLTemplater) error {
   120  	for _, scoop := range ctx.Config.Scoops {
   121  		err := doRun(ctx, scoop, cl)
   122  		if err != nil {
   123  			return err
   124  		}
   125  	}
   126  	return nil
   127  }
   128  
   129  func doRun(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater) error {
   130  	filters := []artifact.Filter{
   131  		artifact.ByGoos("windows"),
   132  		artifact.ByType(artifact.UploadableArchive),
   133  		artifact.Or(
   134  			artifact.And(
   135  				artifact.ByGoarch("amd64"),
   136  				artifact.ByGoamd64(scoop.Goamd64),
   137  			),
   138  			artifact.ByGoarch("arm64"),
   139  			artifact.ByGoarch("386"),
   140  		),
   141  	}
   142  
   143  	if len(scoop.IDs) > 0 {
   144  		filters = append(filters, artifact.ByIDs(scoop.IDs...))
   145  	}
   146  
   147  	filtered := ctx.Artifacts.Filter(artifact.And(filters...))
   148  	archives := filtered.List()
   149  	for _, platArchives := range filtered.GroupByPlatform() {
   150  		// there might be multiple archives, but only of for each platform
   151  		if len(platArchives) != 1 {
   152  			return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives}
   153  		}
   154  	}
   155  	// handle no archives found whatsoever
   156  	if len(archives) == 0 {
   157  		return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives}
   158  	}
   159  
   160  	tp := tmpl.New(ctx)
   161  
   162  	if err := tp.ApplyAll(
   163  		&scoop.Name,
   164  		&scoop.Description,
   165  		&scoop.Homepage,
   166  		&scoop.SkipUpload,
   167  	); err != nil {
   168  		return err
   169  	}
   170  
   171  	ref, err := client.TemplateRef(tmpl.New(ctx).Apply, scoop.Repository)
   172  	if err != nil {
   173  		return err
   174  	}
   175  	scoop.Repository = ref
   176  
   177  	data, err := dataFor(ctx, scoop, cl, archives)
   178  	if err != nil {
   179  		return err
   180  	}
   181  	content, err := doBuildManifest(data)
   182  	if err != nil {
   183  		return err
   184  	}
   185  
   186  	filename := scoop.Name + ".json"
   187  	path := filepath.Join(ctx.Config.Dist, "scoop", scoop.Directory, filename)
   188  	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
   189  		return err
   190  	}
   191  	log.WithField("manifest", path).Info("writing")
   192  	if err := os.WriteFile(path, content.Bytes(), 0o644); err != nil {
   193  		return fmt.Errorf("failed to write scoop manifest: %w", err)
   194  	}
   195  
   196  	ctx.Artifacts.Add(&artifact.Artifact{
   197  		Name: filename,
   198  		Path: path,
   199  		Type: artifact.ScoopManifest,
   200  		Extra: map[string]interface{}{
   201  			scoopConfigExtra: scoop,
   202  		},
   203  	})
   204  	return nil
   205  }
   206  
   207  func publishAll(ctx *context.Context, cli client.Client) error {
   208  	// even if one of them skips, we run them all, and then show return the
   209  	// skips all at once. this is needed so we actually create the
   210  	// `dist/foo.json` file, which is useful for debugging.
   211  	skips := pipe.SkipMemento{}
   212  	for _, manifest := range ctx.Artifacts.Filter(artifact.ByType(artifact.ScoopManifest)).List() {
   213  		err := doPublish(ctx, manifest, cli)
   214  		if err != nil && pipe.IsSkip(err) {
   215  			skips.Remember(err)
   216  			continue
   217  		}
   218  		if err != nil {
   219  			return err
   220  		}
   221  	}
   222  	return skips.Evaluate()
   223  }
   224  
   225  func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Client) error {
   226  	scoop, err := artifact.Extra[config.Scoop](*manifest, scoopConfigExtra)
   227  	if err != nil {
   228  		return err
   229  	}
   230  
   231  	if strings.TrimSpace(scoop.SkipUpload) == "true" {
   232  		return pipe.Skip("scoop.skip_upload is true")
   233  	}
   234  	if strings.TrimSpace(scoop.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
   235  		return pipe.Skip("release is prerelease")
   236  	}
   237  
   238  	commitMessage, err := tmpl.New(ctx).Apply(scoop.CommitMessageTemplate)
   239  	if err != nil {
   240  		return err
   241  	}
   242  
   243  	author, err := commitauthor.Get(ctx, scoop.CommitAuthor)
   244  	if err != nil {
   245  		return err
   246  	}
   247  
   248  	content, err := os.ReadFile(manifest.Path)
   249  	if err != nil {
   250  		return err
   251  	}
   252  
   253  	repo := client.RepoFromRef(scoop.Repository)
   254  	gpath := path.Join(scoop.Directory, manifest.Name)
   255  
   256  	if scoop.Repository.Git.URL != "" {
   257  		return client.NewGitUploadClient(repo.Branch).
   258  			CreateFile(ctx, author, repo, content, gpath, commitMessage)
   259  	}
   260  
   261  	cl, err = client.NewIfToken(ctx, cl, scoop.Repository.Token)
   262  	if err != nil {
   263  		return err
   264  	}
   265  
   266  	base := client.Repo{
   267  		Name:   scoop.Repository.PullRequest.Base.Name,
   268  		Owner:  scoop.Repository.PullRequest.Base.Owner,
   269  		Branch: scoop.Repository.PullRequest.Base.Branch,
   270  	}
   271  
   272  	// try to sync branch
   273  	fscli, ok := cl.(client.ForkSyncer)
   274  	if ok && scoop.Repository.PullRequest.Enabled {
   275  		if err := fscli.SyncFork(ctx, repo, base); err != nil {
   276  			log.WithError(err).Warn("could not sync fork")
   277  		}
   278  	}
   279  
   280  	if err := cl.CreateFile(ctx, author, repo, content, gpath, commitMessage); err != nil {
   281  		return err
   282  	}
   283  
   284  	if !scoop.Repository.PullRequest.Enabled {
   285  		log.Debug("scoop.pull_request disabled")
   286  		return nil
   287  	}
   288  
   289  	log.Info("scoop.pull_request enabled, creating a PR")
   290  	pcl, ok := cl.(client.PullRequestOpener)
   291  	if !ok {
   292  		return fmt.Errorf("client does not support pull requests")
   293  	}
   294  
   295  	return pcl.OpenPullRequest(ctx, base, repo, commitMessage, scoop.Repository.PullRequest.Draft)
   296  }
   297  
   298  // Manifest represents a scoop.sh App Manifest.
   299  // more info: https://github.com/lukesampson/scoop/wiki/App-Manifests
   300  type Manifest struct {
   301  	Version      string              `json:"version"`                // The version of the app that this manifest installs.
   302  	Architecture map[string]Resource `json:"architecture"`           // `architecture`: If the app has 32- and 64-bit versions, architecture can be used to wrap the differences.
   303  	Homepage     string              `json:"homepage,omitempty"`     // `homepage`: The home page for the program.
   304  	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.
   305  	Description  string              `json:"description,omitempty"`  // Description of the app
   306  	Persist      []string            `json:"persist,omitempty"`      // Persist data between updates
   307  	PreInstall   []string            `json:"pre_install,omitempty"`  // An array of strings, of the commands to be executed before an application is installed.
   308  	PostInstall  []string            `json:"post_install,omitempty"` // An array of strings, of the commands to be executed after an application is installed.
   309  	Depends      []string            `json:"depends,omitempty"`      // A string or an array of strings.
   310  	Shortcuts    [][]string          `json:"shortcuts,omitempty"`    // A two-dimensional array of string, specifies the shortcut values to make available in the startmenu.
   311  }
   312  
   313  // Resource represents a combination of a url and a binary name for an architecture.
   314  type Resource struct {
   315  	URL  string   `json:"url"`  // URL to the archive
   316  	Bin  []string `json:"bin"`  // name of binary inside the archive
   317  	Hash string   `json:"hash"` // the archive checksum
   318  }
   319  
   320  func doBuildManifest(manifest Manifest) (bytes.Buffer, error) {
   321  	var result bytes.Buffer
   322  	data, err := json.MarshalIndent(manifest, "", "    ")
   323  	if err != nil {
   324  		return result, err
   325  	}
   326  	_, err = result.Write(data)
   327  	return result, err
   328  }
   329  
   330  func dataFor(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (Manifest, error) {
   331  	manifest := Manifest{
   332  		Version:      ctx.Version,
   333  		Architecture: map[string]Resource{},
   334  		Homepage:     scoop.Homepage,
   335  		License:      scoop.License,
   336  		Description:  scoop.Description,
   337  		Persist:      scoop.Persist,
   338  		PreInstall:   scoop.PreInstall,
   339  		PostInstall:  scoop.PostInstall,
   340  		Depends:      scoop.Depends,
   341  		Shortcuts:    scoop.Shortcuts,
   342  	}
   343  
   344  	if scoop.URLTemplate == "" {
   345  		url, err := cl.ReleaseURLTemplate(ctx)
   346  		if err != nil {
   347  			return manifest, err
   348  		}
   349  		scoop.URLTemplate = url
   350  	}
   351  
   352  	for _, artifact := range artifacts {
   353  		if artifact.Goos != "windows" {
   354  			continue
   355  		}
   356  
   357  		var arch string
   358  		switch artifact.Goarch {
   359  		case "386":
   360  			arch = "32bit"
   361  		case "amd64":
   362  			arch = "64bit"
   363  		case "arm64":
   364  			arch = "arm64"
   365  		default:
   366  			continue
   367  		}
   368  
   369  		url, err := tmpl.New(ctx).WithArtifact(artifact).Apply(scoop.URLTemplate)
   370  		if err != nil {
   371  			return manifest, err
   372  		}
   373  
   374  		sum, err := artifact.Checksum("sha256")
   375  		if err != nil {
   376  			return manifest, err
   377  		}
   378  
   379  		log.
   380  			WithField("artifactExtras", artifact.Extra).
   381  			WithField("fromURLTemplate", scoop.URLTemplate).
   382  			WithField("templatedBrewURL", url).
   383  			WithField("sum", sum).
   384  			Debug("scoop url templating")
   385  
   386  		binaries, err := binaries(*artifact)
   387  		if err != nil {
   388  			return manifest, err
   389  		}
   390  
   391  		manifest.Architecture[arch] = Resource{
   392  			URL:  url,
   393  			Bin:  binaries,
   394  			Hash: sum,
   395  		}
   396  	}
   397  
   398  	return manifest, nil
   399  }
   400  
   401  func binaries(a artifact.Artifact) ([]string, error) {
   402  	// nolint: prealloc
   403  	var result []string
   404  	wrap := artifact.ExtraOr(a, artifact.ExtraWrappedIn, "")
   405  	bins, err := artifact.Extra[[]string](a, artifact.ExtraBinaries)
   406  	if err != nil {
   407  		return nil, err
   408  	}
   409  	for _, b := range bins {
   410  		result = append(result, filepath.Join(wrap, b))
   411  	}
   412  	return result, nil
   413  }