github.com/goreleaser/goreleaser@v1.25.1/internal/pipe/winget/winget.go (about)

     1  package winget
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path/filepath"
     7  	"regexp"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/caarlos0/log"
    12  	"github.com/goreleaser/goreleaser/internal/artifact"
    13  	"github.com/goreleaser/goreleaser/internal/client"
    14  	"github.com/goreleaser/goreleaser/internal/commitauthor"
    15  	"github.com/goreleaser/goreleaser/internal/pipe"
    16  	"github.com/goreleaser/goreleaser/internal/skips"
    17  	"github.com/goreleaser/goreleaser/internal/tmpl"
    18  	"github.com/goreleaser/goreleaser/pkg/config"
    19  	"github.com/goreleaser/goreleaser/pkg/context"
    20  )
    21  
    22  var (
    23  	errNoRepoName               = pipe.Skip("winget.repository.name is required")
    24  	errNoPublisher              = pipe.Skip("winget.publisher is required")
    25  	errNoLicense                = pipe.Skip("winget.license is required")
    26  	errNoShortDescription       = pipe.Skip("winget.short_description is required")
    27  	errInvalidPackageIdentifier = pipe.Skip("winget.package_identifier is invalid")
    28  	errSkipUpload               = pipe.Skip("winget.skip_upload is set")
    29  	errSkipUploadAuto           = pipe.Skip("winget.skip_upload is set to 'auto', and current version is a pre-release")
    30  	errMultipleArchives         = pipe.Skip("found multiple archives for the same platform, please consider filtering by id")
    31  	errMixedFormats             = pipe.Skip("found archives with multiple formats (.exe and .zip)")
    32  
    33  	// copied from winget src
    34  	packageIdentifierValid = regexp.MustCompile("^[^\\.\\s\\\\/:\\*\\?\"<>\\|\\x01-\\x1f]{1,32}(\\.[^\\.\\s\\\\/:\\*\\?\"<>\\|\\x01-\\x1f]{1,32}){1,7}$")
    35  )
    36  
    37  type errNoArchivesFound struct {
    38  	goamd64 string
    39  	ids     []string
    40  }
    41  
    42  func (e errNoArchivesFound) Error() string {
    43  	return fmt.Sprintf("no zip archives found matching goos=[windows] goarch=[amd64 386] goamd64=%s ids=%v", e.goamd64, e.ids)
    44  }
    45  
    46  const wingetConfigExtra = "WingetConfig"
    47  
    48  type Pipe struct{}
    49  
    50  func (Pipe) String() string        { return "winget" }
    51  func (Pipe) ContinueOnError() bool { return true }
    52  func (p Pipe) Skip(ctx *context.Context) bool {
    53  	return skips.Any(ctx, skips.Winget) || len(ctx.Config.Winget) == 0
    54  }
    55  
    56  func (Pipe) Default(ctx *context.Context) error {
    57  	for i := range ctx.Config.Winget {
    58  		winget := &ctx.Config.Winget[i]
    59  
    60  		winget.CommitAuthor = commitauthor.Default(winget.CommitAuthor)
    61  
    62  		if winget.CommitMessageTemplate == "" {
    63  			winget.CommitMessageTemplate = "New version: {{ .PackageIdentifier }} {{ .Version }}"
    64  		}
    65  		if winget.Name == "" {
    66  			winget.Name = ctx.Config.ProjectName
    67  		}
    68  		if winget.Goamd64 == "" {
    69  			winget.Goamd64 = "v1"
    70  		}
    71  	}
    72  
    73  	return nil
    74  }
    75  
    76  func (p Pipe) Run(ctx *context.Context) error {
    77  	cli, err := client.NewReleaseClient(ctx)
    78  	if err != nil {
    79  		return err
    80  	}
    81  
    82  	return p.runAll(ctx, cli)
    83  }
    84  
    85  // Publish .
    86  func (p Pipe) Publish(ctx *context.Context) error {
    87  	cli, err := client.New(ctx)
    88  	if err != nil {
    89  		return err
    90  	}
    91  	return p.publishAll(ctx, cli)
    92  }
    93  
    94  func (p Pipe) runAll(ctx *context.Context, cli client.ReleaseURLTemplater) error {
    95  	for _, winget := range ctx.Config.Winget {
    96  		err := p.doRun(ctx, winget, cli)
    97  		if err != nil {
    98  			return err
    99  		}
   100  	}
   101  	return nil
   102  }
   103  
   104  func (p Pipe) doRun(ctx *context.Context, winget config.Winget, cl client.ReleaseURLTemplater) error {
   105  	if winget.Repository.Name == "" {
   106  		return errNoRepoName
   107  	}
   108  
   109  	tp := tmpl.New(ctx)
   110  
   111  	err := tp.ApplyAll(
   112  		&winget.Publisher,
   113  		&winget.Name,
   114  		&winget.Author,
   115  		&winget.PublisherURL,
   116  		&winget.PublisherSupportURL,
   117  		&winget.Homepage,
   118  		&winget.SkipUpload,
   119  		&winget.Description,
   120  		&winget.ShortDescription,
   121  		&winget.ReleaseNotesURL,
   122  		&winget.Path,
   123  		&winget.Copyright,
   124  		&winget.CopyrightURL,
   125  		&winget.License,
   126  		&winget.LicenseURL,
   127  	)
   128  	if err != nil {
   129  		return err
   130  	}
   131  
   132  	if winget.Publisher == "" {
   133  		return errNoPublisher
   134  	}
   135  
   136  	if winget.License == "" {
   137  		return errNoLicense
   138  	}
   139  
   140  	winget.Repository, err = client.TemplateRef(tp.Apply, winget.Repository)
   141  	if err != nil {
   142  		return err
   143  	}
   144  
   145  	if winget.ShortDescription == "" {
   146  		return errNoShortDescription
   147  	}
   148  
   149  	winget.ReleaseNotes, err = tp.WithExtraFields(tmpl.Fields{
   150  		"Changelog": ctx.ReleaseNotes,
   151  	}).Apply(winget.ReleaseNotes)
   152  	if err != nil {
   153  		return err
   154  	}
   155  
   156  	if winget.URLTemplate == "" {
   157  		winget.URLTemplate, err = cl.ReleaseURLTemplate(ctx)
   158  		if err != nil {
   159  			return err
   160  		}
   161  	}
   162  
   163  	if winget.Path == "" {
   164  		winget.Path = filepath.Join("manifests", strings.ToLower(string(winget.Publisher[0])), winget.Publisher, winget.Name, ctx.Version)
   165  	}
   166  
   167  	filters := []artifact.Filter{
   168  		artifact.ByGoos("windows"),
   169  		artifact.Or(
   170  			artifact.And(
   171  				artifact.ByFormats("zip"),
   172  				artifact.ByType(artifact.UploadableArchive),
   173  			),
   174  			artifact.ByType(artifact.UploadableBinary),
   175  		),
   176  		artifact.Or(
   177  			artifact.ByGoarch("386"),
   178  			artifact.ByGoarch("arm64"),
   179  			artifact.And(
   180  				artifact.ByGoamd64(winget.Goamd64),
   181  				artifact.ByGoarch("amd64"),
   182  			),
   183  		),
   184  	}
   185  	if len(winget.IDs) > 0 {
   186  		filters = append(filters, artifact.ByIDs(winget.IDs...))
   187  	}
   188  	archives := ctx.Artifacts.Filter(artifact.And(filters...)).List()
   189  	if len(archives) == 0 {
   190  		return errNoArchivesFound{
   191  			goamd64: winget.Goamd64,
   192  			ids:     winget.IDs,
   193  		}
   194  	}
   195  
   196  	if winget.PackageIdentifier == "" {
   197  		winget.PackageIdentifier = winget.Publisher + "." + winget.Name
   198  	}
   199  
   200  	if !packageIdentifierValid.MatchString(winget.PackageIdentifier) {
   201  		return fmt.Errorf("%w: %s", errInvalidPackageIdentifier, winget.PackageIdentifier)
   202  	}
   203  
   204  	if err := createYAML(ctx, winget, Version{
   205  		PackageIdentifier: winget.PackageIdentifier,
   206  		PackageVersion:    ctx.Version,
   207  		DefaultLocale:     defaultLocale,
   208  		ManifestType:      "version",
   209  		ManifestVersion:   manifestVersion,
   210  	}, artifact.WingetVersion); err != nil {
   211  		return err
   212  	}
   213  
   214  	installer, err := makeInstaller(ctx, winget, archives)
   215  	if err != nil {
   216  		return err
   217  	}
   218  
   219  	if err := createYAML(ctx, winget, installer, artifact.WingetInstaller); err != nil {
   220  		return err
   221  	}
   222  
   223  	return createYAML(ctx, winget, Locale{
   224  		PackageIdentifier:   winget.PackageIdentifier,
   225  		PackageVersion:      ctx.Version,
   226  		PackageLocale:       defaultLocale,
   227  		Publisher:           winget.Publisher,
   228  		PublisherURL:        winget.PublisherURL,
   229  		PublisherSupportURL: winget.PublisherSupportURL,
   230  		Author:              winget.Author,
   231  		PackageName:         winget.Name,
   232  		PackageURL:          winget.Homepage,
   233  		License:             winget.License,
   234  		LicenseURL:          winget.LicenseURL,
   235  		Copyright:           winget.Copyright,
   236  		CopyrightURL:        winget.CopyrightURL,
   237  		ShortDescription:    winget.ShortDescription,
   238  		Description:         winget.Description,
   239  		Moniker:             winget.Name,
   240  		Tags:                winget.Tags,
   241  		ReleaseNotes:        winget.ReleaseNotes,
   242  		ReleaseNotesURL:     winget.ReleaseNotesURL,
   243  		ManifestType:        "defaultLocale",
   244  		ManifestVersion:     manifestVersion,
   245  	}, artifact.WingetDefaultLocale)
   246  }
   247  
   248  func (p Pipe) publishAll(ctx *context.Context, cli client.Client) error {
   249  	skips := pipe.SkipMemento{}
   250  	for _, files := range ctx.Artifacts.Filter(artifact.Or(
   251  		artifact.ByType(artifact.WingetInstaller),
   252  		artifact.ByType(artifact.WingetVersion),
   253  		artifact.ByType(artifact.WingetDefaultLocale),
   254  	)).GroupByID() {
   255  		err := doPublish(ctx, cli, files)
   256  		if err != nil && pipe.IsSkip(err) {
   257  			skips.Remember(err)
   258  			continue
   259  		}
   260  		if err != nil {
   261  			return err
   262  		}
   263  	}
   264  	return skips.Evaluate()
   265  }
   266  
   267  func doPublish(ctx *context.Context, cl client.Client, wingets []*artifact.Artifact) error {
   268  	winget, err := artifact.Extra[config.Winget](*wingets[0], wingetConfigExtra)
   269  	if err != nil {
   270  		return err
   271  	}
   272  
   273  	if strings.TrimSpace(winget.SkipUpload) == "true" {
   274  		return errSkipUpload
   275  	}
   276  
   277  	if strings.TrimSpace(winget.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
   278  		return errSkipUploadAuto
   279  	}
   280  
   281  	msg, err := tmpl.New(ctx).WithExtraFields(tmpl.Fields{
   282  		"PackageIdentifier": winget.PackageIdentifier,
   283  	}).Apply(winget.CommitMessageTemplate)
   284  	if err != nil {
   285  		return err
   286  	}
   287  
   288  	author, err := commitauthor.Get(ctx, winget.CommitAuthor)
   289  	if err != nil {
   290  		return err
   291  	}
   292  
   293  	repo := client.RepoFromRef(winget.Repository)
   294  
   295  	var files []client.RepoFile
   296  	for _, pkg := range wingets {
   297  		content, err := os.ReadFile(pkg.Path)
   298  		if err != nil {
   299  			return err
   300  		}
   301  		files = append(files, client.RepoFile{
   302  			Content:    content,
   303  			Path:       filepath.Join(winget.Path, pkg.Name),
   304  			Identifier: repoFileID(pkg.Type),
   305  		})
   306  	}
   307  
   308  	if winget.Repository.Git.URL != "" {
   309  		return client.NewGitUploadClient(repo.Branch).
   310  			CreateFiles(ctx, author, repo, msg, files)
   311  	}
   312  
   313  	cl, err = client.NewIfToken(ctx, cl, winget.Repository.Token)
   314  	if err != nil {
   315  		return err
   316  	}
   317  
   318  	base := client.Repo{
   319  		Name:   winget.Repository.PullRequest.Base.Name,
   320  		Owner:  winget.Repository.PullRequest.Base.Owner,
   321  		Branch: winget.Repository.PullRequest.Base.Branch,
   322  	}
   323  
   324  	// try to sync branch
   325  	fscli, ok := cl.(client.ForkSyncer)
   326  	if ok && winget.Repository.PullRequest.Enabled {
   327  		if err := fscli.SyncFork(ctx, repo, base); err != nil {
   328  			log.WithError(err).Warn("could not sync fork")
   329  		}
   330  	}
   331  
   332  	for _, file := range files {
   333  		if err := cl.CreateFile(
   334  			ctx,
   335  			author,
   336  			repo,
   337  			file.Content,
   338  			file.Path,
   339  			msg+": add "+file.Identifier,
   340  		); err != nil {
   341  			return err
   342  		}
   343  	}
   344  
   345  	if !winget.Repository.PullRequest.Enabled {
   346  		log.Debug("wingets.pull_request disabled")
   347  		return nil
   348  	}
   349  
   350  	log.Info("winget.pull_request enabled, creating a PR")
   351  	pcl, ok := cl.(client.PullRequestOpener)
   352  	if !ok {
   353  		return fmt.Errorf("client does not support pull requests")
   354  	}
   355  
   356  	return pcl.OpenPullRequest(ctx, base, repo, msg, winget.Repository.PullRequest.Draft)
   357  }
   358  
   359  func langserverLineFor(tp artifact.Type) string {
   360  	switch tp {
   361  	case artifact.WingetInstaller:
   362  		return installerLangServer
   363  	case artifact.WingetDefaultLocale:
   364  		return defaultLocaleLangServer
   365  	default:
   366  		return versionLangServer
   367  	}
   368  }
   369  
   370  func extFor(tp artifact.Type) string {
   371  	switch tp {
   372  	case artifact.WingetVersion:
   373  		return ".yaml"
   374  	case artifact.WingetInstaller:
   375  		return ".installer.yaml"
   376  	case artifact.WingetDefaultLocale:
   377  		return ".locale." + defaultLocale + ".yaml"
   378  	default:
   379  		// should never happen
   380  		return ""
   381  	}
   382  }
   383  
   384  func repoFileID(tp artifact.Type) string {
   385  	switch tp {
   386  	case artifact.WingetVersion:
   387  		return "version"
   388  	case artifact.WingetInstaller:
   389  		return "installer"
   390  	case artifact.WingetDefaultLocale:
   391  		return "locale"
   392  	default:
   393  		// should never happen
   394  		return ""
   395  	}
   396  }
   397  
   398  func installerItemFilesFor(archive artifact.Artifact) []InstallerItemFile {
   399  	var files []InstallerItemFile
   400  	folder := artifact.ExtraOr(archive, artifact.ExtraWrappedIn, ".")
   401  	for _, bin := range artifact.ExtraOr(archive, artifact.ExtraBinaries, []string{}) {
   402  		files = append(files, InstallerItemFile{
   403  			RelativeFilePath:     strings.ReplaceAll(filepath.Join(folder, bin), "/", "\\"),
   404  			PortableCommandAlias: strings.TrimSuffix(filepath.Base(bin), ".exe"),
   405  		})
   406  	}
   407  	return files
   408  }
   409  
   410  func makeInstaller(ctx *context.Context, winget config.Winget, archives []*artifact.Artifact) (Installer, error) {
   411  	tp := tmpl.New(ctx)
   412  	var deps []PackageDependency
   413  	for _, dep := range winget.Dependencies {
   414  		if err := tp.ApplyAll(&dep.MinimumVersion, &dep.PackageIdentifier); err != nil {
   415  			return Installer{}, err
   416  		}
   417  		deps = append(deps, PackageDependency{
   418  			PackageIdentifier: dep.PackageIdentifier,
   419  			MinimumVersion:    dep.MinimumVersion,
   420  		})
   421  	}
   422  
   423  	installer := Installer{
   424  		PackageIdentifier: winget.PackageIdentifier,
   425  		PackageVersion:    ctx.Version,
   426  		InstallerLocale:   defaultLocale,
   427  		InstallerType:     "zip",
   428  		Commands:          []string{},
   429  		ReleaseDate:       ctx.Date.Format(time.DateOnly),
   430  		Installers:        []InstallerItem{},
   431  		ManifestType:      "installer",
   432  		ManifestVersion:   manifestVersion,
   433  		Dependencies: Dependencies{
   434  			PackageDependencies: deps,
   435  		},
   436  	}
   437  
   438  	var amd64Count, i386count, zipCount, binaryCount int
   439  	for _, archive := range archives {
   440  		sha256, err := archive.Checksum("sha256")
   441  		if err != nil {
   442  			return Installer{}, err
   443  		}
   444  		url, err := tmpl.New(ctx).WithArtifact(archive).Apply(winget.URLTemplate)
   445  		if err != nil {
   446  			return Installer{}, err
   447  		}
   448  		item := InstallerItem{
   449  			Architecture:    fromGoArch[archive.Goarch],
   450  			InstallerURL:    url,
   451  			InstallerSha256: sha256,
   452  			UpgradeBehavior: "uninstallPrevious",
   453  		}
   454  		if archive.Format() == "zip" {
   455  			zipCount++
   456  			installer.InstallerType = "zip"
   457  			item.NestedInstallerType = "portable"
   458  			item.NestedInstallerFiles = installerItemFilesFor(*archive)
   459  		} else {
   460  			binaryCount++
   461  			installer.InstallerType = "portable"
   462  			installer.Commands = []string{winget.Name}
   463  		}
   464  		installer.Installers = append(installer.Installers, item)
   465  		switch archive.Goarch {
   466  		case "386":
   467  			i386count++
   468  		case "amd64":
   469  			amd64Count++
   470  		}
   471  	}
   472  
   473  	if binaryCount > 0 && zipCount > 0 {
   474  		return Installer{}, errMixedFormats
   475  	}
   476  
   477  	if i386count > 1 || amd64Count > 1 {
   478  		return Installer{}, errMultipleArchives
   479  	}
   480  
   481  	return installer, nil
   482  }