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

     1  package nix
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"errors"
     7  	"fmt"
     8  	"os"
     9  	"os/exec"
    10  	"path"
    11  	"path/filepath"
    12  	"slices"
    13  	"sort"
    14  	"strings"
    15  	"text/template"
    16  
    17  	"github.com/caarlos0/log"
    18  	"github.com/goreleaser/goreleaser/internal/artifact"
    19  	"github.com/goreleaser/goreleaser/internal/client"
    20  	"github.com/goreleaser/goreleaser/internal/commitauthor"
    21  	"github.com/goreleaser/goreleaser/internal/pipe"
    22  	"github.com/goreleaser/goreleaser/internal/skips"
    23  	"github.com/goreleaser/goreleaser/internal/tmpl"
    24  	"github.com/goreleaser/goreleaser/pkg/config"
    25  	"github.com/goreleaser/goreleaser/pkg/context"
    26  	"golang.org/x/exp/maps"
    27  )
    28  
    29  const nixConfigExtra = "NixConfig"
    30  
    31  // ErrMultipleArchivesSamePlatform happens when the config yields multiple
    32  // archives for the same platform.
    33  var ErrMultipleArchivesSamePlatform = errors.New("one nixpkg can handle only one archive of each OS/Arch combination")
    34  
    35  type errNoArchivesFound struct {
    36  	goamd64 string
    37  	ids     []string
    38  }
    39  
    40  func (e errNoArchivesFound) Error() string {
    41  	return fmt.Sprintf("no archives found matching goos=[darwin linux] goarch=[amd64 arm arm64 386] goarm=[6 7] goamd64=%s ids=%v", e.goamd64, e.ids)
    42  }
    43  
    44  var (
    45  	errNoRepoName     = pipe.Skip("repository name is not set")
    46  	errSkipUpload     = pipe.Skip("nix.skip_upload is set")
    47  	errSkipUploadAuto = pipe.Skip("nix.skip_upload is set to 'auto', and current version is a pre-release")
    48  	errInvalidLicense = errors.New("nix.license is invalid")
    49  )
    50  
    51  // NewBuild returns a pipe to be used in the build phase.
    52  func NewBuild() Pipe {
    53  	return Pipe{buildShaPrefetcher{}}
    54  }
    55  
    56  // NewPublish returns a pipe to be used in the publish phase.
    57  func NewPublish() Pipe {
    58  	return Pipe{publishShaPrefetcher{
    59  		bin: nixPrefetchURLBin,
    60  	}}
    61  }
    62  
    63  type Pipe struct {
    64  	prefetcher shaPrefetcher
    65  }
    66  
    67  func (Pipe) String() string                           { return "nixpkgs" }
    68  func (Pipe) ContinueOnError() bool                    { return true }
    69  func (Pipe) Dependencies(_ *context.Context) []string { return []string{"nix-prefetch-url"} }
    70  func (p Pipe) Skip(ctx *context.Context) bool {
    71  	return skips.Any(ctx, skips.Nix) || len(ctx.Config.Nix) == 0 || !p.prefetcher.Available()
    72  }
    73  
    74  func (Pipe) Default(ctx *context.Context) error {
    75  	for i := range ctx.Config.Nix {
    76  		nix := &ctx.Config.Nix[i]
    77  
    78  		nix.CommitAuthor = commitauthor.Default(nix.CommitAuthor)
    79  
    80  		if nix.CommitMessageTemplate == "" {
    81  			nix.CommitMessageTemplate = "{{ .ProjectName }}: {{ .PreviousTag }} -> {{ .Tag }}"
    82  		}
    83  		if nix.Name == "" {
    84  			nix.Name = ctx.Config.ProjectName
    85  		}
    86  		if nix.Goamd64 == "" {
    87  			nix.Goamd64 = "v1"
    88  		}
    89  		if nix.License != "" && !slices.Contains(validLicenses, nix.License) {
    90  			return fmt.Errorf("%w: %s", errInvalidLicense, nix.License)
    91  		}
    92  	}
    93  
    94  	return nil
    95  }
    96  
    97  func (p Pipe) Run(ctx *context.Context) error {
    98  	cli, err := client.NewReleaseClient(ctx)
    99  	if err != nil {
   100  		return err
   101  	}
   102  
   103  	return p.runAll(ctx, cli)
   104  }
   105  
   106  // Publish .
   107  func (p Pipe) Publish(ctx *context.Context) error {
   108  	cli, err := client.New(ctx)
   109  	if err != nil {
   110  		return err
   111  	}
   112  	return p.publishAll(ctx, cli)
   113  }
   114  
   115  func (p Pipe) runAll(ctx *context.Context, cli client.ReleaseURLTemplater) error {
   116  	for _, nix := range ctx.Config.Nix {
   117  		err := p.doRun(ctx, nix, cli)
   118  		if err != nil {
   119  			return err
   120  		}
   121  	}
   122  	return nil
   123  }
   124  
   125  func (p Pipe) publishAll(ctx *context.Context, cli client.Client) error {
   126  	skips := pipe.SkipMemento{}
   127  	for _, nix := range ctx.Artifacts.Filter(artifact.ByType(artifact.Nixpkg)).List() {
   128  		err := doPublish(ctx, p.prefetcher, cli, nix)
   129  		if err != nil && pipe.IsSkip(err) {
   130  			skips.Remember(err)
   131  			continue
   132  		}
   133  		if err != nil {
   134  			return err
   135  		}
   136  	}
   137  	return skips.Evaluate()
   138  }
   139  
   140  func (p Pipe) doRun(ctx *context.Context, nix config.Nix, cl client.ReleaseURLTemplater) error {
   141  	if nix.Repository.Name == "" {
   142  		return errNoRepoName
   143  	}
   144  
   145  	tp := tmpl.New(ctx)
   146  
   147  	err := tp.ApplyAll(
   148  		&nix.Name,
   149  		&nix.SkipUpload,
   150  		&nix.Homepage,
   151  		&nix.Description,
   152  		&nix.Path,
   153  	)
   154  	if err != nil {
   155  		return err
   156  	}
   157  
   158  	nix.Repository, err = client.TemplateRef(tmpl.New(ctx).Apply, nix.Repository)
   159  	if err != nil {
   160  		return err
   161  	}
   162  
   163  	if nix.Path == "" {
   164  		nix.Path = path.Join("pkgs", nix.Name, "default.nix")
   165  	}
   166  
   167  	path := filepath.Join(ctx.Config.Dist, "nix", nix.Path)
   168  	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
   169  		return err
   170  	}
   171  
   172  	content, err := preparePkg(ctx, nix, cl, p.prefetcher)
   173  	if err != nil {
   174  		return err
   175  	}
   176  
   177  	log.WithField("nixpkg", path).Info("writing")
   178  	if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint: gosec
   179  		return fmt.Errorf("failed to write nixpkg: %w", err)
   180  	}
   181  
   182  	ctx.Artifacts.Add(&artifact.Artifact{
   183  		Name: filepath.Base(path),
   184  		Path: path,
   185  		Type: artifact.Nixpkg,
   186  		Extra: map[string]interface{}{
   187  			nixConfigExtra: nix,
   188  		},
   189  	})
   190  
   191  	return nil
   192  }
   193  
   194  func preparePkg(
   195  	ctx *context.Context,
   196  	nix config.Nix,
   197  	cli client.ReleaseURLTemplater,
   198  	prefetcher shaPrefetcher,
   199  ) (string, error) {
   200  	filters := []artifact.Filter{
   201  		artifact.Or(
   202  			artifact.ByGoos("darwin"),
   203  			artifact.ByGoos("linux"),
   204  		),
   205  		artifact.Or(
   206  			artifact.And(
   207  				artifact.ByGoarch("amd64"),
   208  				artifact.ByGoamd64(nix.Goamd64),
   209  			),
   210  			artifact.And(
   211  				artifact.ByGoarch("arm"),
   212  				artifact.Or(
   213  					artifact.ByGoarm("6"),
   214  					artifact.ByGoarm("7"),
   215  				),
   216  			),
   217  			artifact.ByGoarch("arm64"),
   218  			artifact.ByGoarch("386"),
   219  			artifact.ByGoarch("all"),
   220  		),
   221  		artifact.And(
   222  			artifact.ByFormats("zip", "tar.gz"),
   223  			artifact.ByType(artifact.UploadableArchive),
   224  		),
   225  		artifact.OnlyReplacingUnibins,
   226  	}
   227  	if len(nix.IDs) > 0 {
   228  		filters = append(filters, artifact.ByIDs(nix.IDs...))
   229  	}
   230  
   231  	archives := ctx.Artifacts.Filter(artifact.And(filters...)).List()
   232  	if len(archives) == 0 {
   233  		return "", errNoArchivesFound{
   234  			goamd64: nix.Goamd64,
   235  			ids:     nix.IDs,
   236  		}
   237  	}
   238  
   239  	if nix.URLTemplate == "" {
   240  		url, err := cli.ReleaseURLTemplate(ctx)
   241  		if err != nil {
   242  			return "", err
   243  		}
   244  		nix.URLTemplate = url
   245  	}
   246  
   247  	installs, err := installs(ctx, nix, archives[0])
   248  	if err != nil {
   249  		return "", err
   250  	}
   251  
   252  	postInstall, err := postInstall(ctx, nix, archives[0])
   253  	if err != nil {
   254  		return "", err
   255  	}
   256  
   257  	inputs := []string{"installShellFiles"}
   258  	dependencies := depNames(nix.Dependencies)
   259  	if len(dependencies) > 0 {
   260  		inputs = append(inputs, "makeWrapper")
   261  		dependencies = append(dependencies, "makeWrapper")
   262  	}
   263  	for _, arch := range archives {
   264  		if arch.Format() == "zip" {
   265  			inputs = append(inputs, "unzip")
   266  			dependencies = append(dependencies, "unzip")
   267  			break
   268  		}
   269  	}
   270  
   271  	data := templateData{
   272  		Name:         nix.Name,
   273  		Version:      ctx.Version,
   274  		Install:      installs,
   275  		PostInstall:  postInstall,
   276  		Archives:     map[string]Archive{},
   277  		SourceRoots:  map[string]string{},
   278  		Description:  nix.Description,
   279  		Homepage:     nix.Homepage,
   280  		License:      nix.License,
   281  		Inputs:       inputs,
   282  		Dependencies: dependencies,
   283  	}
   284  
   285  	platforms := map[string]bool{}
   286  	for _, art := range archives {
   287  		url, err := tmpl.New(ctx).WithArtifact(art).Apply(nix.URLTemplate)
   288  		if err != nil {
   289  			return "", err
   290  		}
   291  		sha, err := prefetcher.Prefetch(url)
   292  		if err != nil {
   293  			return "", err
   294  		}
   295  		archive := Archive{
   296  			URL: url,
   297  			Sha: sha,
   298  		}
   299  
   300  		for _, goarch := range expandGoarch(art.Goarch) {
   301  			key := art.Goos + goarch + art.Goarm
   302  			if _, ok := data.Archives[key]; ok {
   303  				return "", ErrMultipleArchivesSamePlatform
   304  			}
   305  			folder := artifact.ExtraOr(*art, artifact.ExtraWrappedIn, ".")
   306  			if folder == "" {
   307  				folder = "."
   308  			}
   309  			data.SourceRoots[key] = folder
   310  			data.Archives[key] = archive
   311  			plat := goosToPlatform[art.Goos+goarch+art.Goarm]
   312  			platforms[plat] = true
   313  		}
   314  	}
   315  
   316  	if roots := slices.Compact(maps.Values(data.SourceRoots)); len(roots) == 1 {
   317  		data.SourceRoot = roots[0]
   318  	}
   319  	data.Platforms = keys(platforms)
   320  	sort.Strings(data.Platforms)
   321  
   322  	return doBuildPkg(ctx, data)
   323  }
   324  
   325  func expandGoarch(goarch string) []string {
   326  	if goarch == "all" {
   327  		return []string{"amd64", "arm64"}
   328  	}
   329  	return []string{goarch}
   330  }
   331  
   332  var goosToPlatform = map[string]string{
   333  	"linuxamd64":  "x86_64-linux",
   334  	"linuxarm64":  "aarch64-linux",
   335  	"linuxarm6":   "armv6l-linux",
   336  	"linuxarm7":   "armv7l-linux",
   337  	"linux386":    "i686-linux",
   338  	"darwinamd64": "x86_64-darwin",
   339  	"darwinarm64": "aarch64-darwin",
   340  }
   341  
   342  func keys(m map[string]bool) []string {
   343  	keys := make([]string, 0, len(m))
   344  	for k := range m {
   345  		keys = append(keys, k)
   346  	}
   347  	return keys
   348  }
   349  
   350  func doPublish(ctx *context.Context, prefetcher shaPrefetcher, cl client.Client, pkg *artifact.Artifact) error {
   351  	nix, err := artifact.Extra[config.Nix](*pkg, nixConfigExtra)
   352  	if err != nil {
   353  		return err
   354  	}
   355  
   356  	if strings.TrimSpace(nix.SkipUpload) == "true" {
   357  		return errSkipUpload
   358  	}
   359  
   360  	if strings.TrimSpace(nix.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" {
   361  		return errSkipUploadAuto
   362  	}
   363  
   364  	repo := client.RepoFromRef(nix.Repository)
   365  
   366  	gpath := nix.Path
   367  
   368  	msg, err := tmpl.New(ctx).Apply(nix.CommitMessageTemplate)
   369  	if err != nil {
   370  		return err
   371  	}
   372  
   373  	author, err := commitauthor.Get(ctx, nix.CommitAuthor)
   374  	if err != nil {
   375  		return err
   376  	}
   377  
   378  	content, err := preparePkg(ctx, nix, cl, prefetcher)
   379  	if err != nil {
   380  		return err
   381  	}
   382  
   383  	if nix.Repository.Git.URL != "" {
   384  		return client.NewGitUploadClient(repo.Branch).
   385  			CreateFile(ctx, author, repo, []byte(content), gpath, msg)
   386  	}
   387  
   388  	cl, err = client.NewIfToken(ctx, cl, nix.Repository.Token)
   389  	if err != nil {
   390  		return err
   391  	}
   392  
   393  	base := client.Repo{
   394  		Name:   nix.Repository.PullRequest.Base.Name,
   395  		Owner:  nix.Repository.PullRequest.Base.Owner,
   396  		Branch: nix.Repository.PullRequest.Base.Branch,
   397  	}
   398  
   399  	// try to sync branch
   400  	fscli, ok := cl.(client.ForkSyncer)
   401  	if ok && nix.Repository.PullRequest.Enabled {
   402  		if err := fscli.SyncFork(ctx, repo, base); err != nil {
   403  			log.WithError(err).Warn("could not sync fork")
   404  		}
   405  	}
   406  
   407  	if err := cl.CreateFile(ctx, author, repo, []byte(content), gpath, msg); err != nil {
   408  		return err
   409  	}
   410  
   411  	if !nix.Repository.PullRequest.Enabled {
   412  		log.Debug("nix.pull_request disabled")
   413  		return nil
   414  	}
   415  
   416  	log.Info("nix.pull_request enabled, creating a PR")
   417  	pcl, ok := cl.(client.PullRequestOpener)
   418  	if !ok {
   419  		return fmt.Errorf("client does not support pull requests")
   420  	}
   421  
   422  	return pcl.OpenPullRequest(ctx, base, repo, msg, nix.Repository.PullRequest.Draft)
   423  }
   424  
   425  func doBuildPkg(ctx *context.Context, data templateData) (string, error) {
   426  	t, err := template.
   427  		New(data.Name).
   428  		Parse(string(pkgTmpl))
   429  	if err != nil {
   430  		return "", err
   431  	}
   432  	var out bytes.Buffer
   433  	if err := t.Execute(&out, data); err != nil {
   434  		return "", err
   435  	}
   436  
   437  	content, err := tmpl.New(ctx).Apply(out.String())
   438  	if err != nil {
   439  		return "", err
   440  	}
   441  	out.Reset()
   442  
   443  	// Sanitize the template output and get rid of trailing whitespace.
   444  	var (
   445  		r = strings.NewReader(content)
   446  		s = bufio.NewScanner(r)
   447  	)
   448  	for s.Scan() {
   449  		l := strings.TrimRight(s.Text(), " ")
   450  		_, _ = out.WriteString(l)
   451  		_ = out.WriteByte('\n')
   452  	}
   453  	if err := s.Err(); err != nil {
   454  		return "", err
   455  	}
   456  
   457  	return out.String(), nil
   458  }
   459  
   460  func postInstall(ctx *context.Context, nix config.Nix, art *artifact.Artifact) ([]string, error) {
   461  	applied, err := tmpl.New(ctx).WithArtifact(art).Apply(nix.PostInstall)
   462  	if err != nil {
   463  		return nil, err
   464  	}
   465  	return split(applied), nil
   466  }
   467  
   468  func installs(ctx *context.Context, nix config.Nix, art *artifact.Artifact) ([]string, error) {
   469  	tpl := tmpl.New(ctx).WithArtifact(art)
   470  
   471  	extraInstall, err := tpl.Apply(nix.ExtraInstall)
   472  	if err != nil {
   473  		return nil, err
   474  	}
   475  
   476  	install, err := tpl.Apply(nix.Install)
   477  	if err != nil {
   478  		return nil, err
   479  	}
   480  	if install != "" {
   481  		return append(split(install), split(extraInstall)...), nil
   482  	}
   483  
   484  	result := []string{"mkdir -p $out/bin"}
   485  	binInstallFormat := binInstallFormats(nix)
   486  	for _, bin := range artifact.ExtraOr(*art, artifact.ExtraBinaries, []string{}) {
   487  		for _, format := range binInstallFormat {
   488  			result = append(result, fmt.Sprintf(format, bin))
   489  		}
   490  	}
   491  
   492  	log.WithField("install", result).Info("guessing install")
   493  
   494  	return append(result, split(extraInstall)...), nil
   495  }
   496  
   497  func binInstallFormats(nix config.Nix) []string {
   498  	formats := []string{"cp -vr ./%[1]s $out/bin/%[1]s"}
   499  	if len(nix.Dependencies) == 0 {
   500  		return formats
   501  	}
   502  	var deps, linuxDeps, darwinDeps []string
   503  
   504  	for _, dep := range nix.Dependencies {
   505  		switch dep.OS {
   506  		case "darwin":
   507  			darwinDeps = append(darwinDeps, dep.Name)
   508  		case "linux":
   509  			linuxDeps = append(linuxDeps, dep.Name)
   510  		default:
   511  			deps = append(deps, dep.Name)
   512  		}
   513  	}
   514  
   515  	var depStrings []string
   516  
   517  	if len(darwinDeps) > 0 {
   518  		depStrings = append(depStrings, fmt.Sprintf("lib.optionals stdenvNoCC.isDarwin [ %s ]", strings.Join(darwinDeps, " ")))
   519  	}
   520  	if len(linuxDeps) > 0 {
   521  		depStrings = append(depStrings, fmt.Sprintf("lib.optionals stdenvNoCC.isLinux [ %s ]", strings.Join(linuxDeps, " ")))
   522  	}
   523  	if len(deps) > 0 {
   524  		depStrings = append(depStrings, fmt.Sprintf("[ %s ]", strings.Join(deps, " ")))
   525  	}
   526  
   527  	depString := strings.Join(depStrings, " ++ ")
   528  	return append(
   529  		formats,
   530  		"wrapProgram $out/bin/%[1]s --prefix PATH : ${lib.makeBinPath ("+depString+")}",
   531  	)
   532  }
   533  
   534  func split(s string) []string {
   535  	var result []string
   536  	for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
   537  		line := strings.TrimSpace(line)
   538  		if line == "" {
   539  			continue
   540  		}
   541  		result = append(result, line)
   542  	}
   543  	return result
   544  }
   545  
   546  func depNames(deps []config.NixDependency) []string {
   547  	var result []string
   548  	for _, dep := range deps {
   549  		result = append(result, dep.Name)
   550  	}
   551  	return result
   552  }
   553  
   554  type shaPrefetcher interface {
   555  	Prefetch(url string) (string, error)
   556  	Available() bool
   557  }
   558  
   559  const (
   560  	zeroHash          = "0000000000000000000000000000000000000000000000000000"
   561  	nixPrefetchURLBin = "nix-prefetch-url"
   562  )
   563  
   564  type buildShaPrefetcher struct{}
   565  
   566  func (buildShaPrefetcher) Prefetch(_ string) (string, error) { return zeroHash, nil }
   567  func (buildShaPrefetcher) Available() bool                   { return true }
   568  
   569  type publishShaPrefetcher struct {
   570  	bin string
   571  }
   572  
   573  func (p publishShaPrefetcher) Available() bool {
   574  	_, err := exec.LookPath(p.bin)
   575  	if err != nil {
   576  		log.Warnf("%s is not available", p.bin)
   577  	}
   578  	return err == nil
   579  }
   580  
   581  func (p publishShaPrefetcher) Prefetch(url string) (string, error) {
   582  	out, err := exec.Command(p.bin, url).Output()
   583  	outStr := strings.TrimSpace(string(out))
   584  	if err != nil {
   585  		return "", fmt.Errorf("could not prefetch url: %s: %w: %s", url, err, outStr)
   586  	}
   587  	return outStr, nil
   588  }