Repository: goreleaser/goreleaser
Pull request #2839 (2022-01-19 21:05:01)

fix: dedupliate cataloging binary artifacts
This PR makes the following adjustments:
- primarily restores the capability to reference binary artifacts when `sbom[].artifact == binary`
- adds the ability to have templates within `sbom[].env` values
- only prepends `dist` config value to paths that relative paths (takes no action on absolute paths)
- decomposes templating code further for the SBOM pipe and surround with more testing

Why make this change? primarily to unlock more workflows when generating SBOMs.

Not all tooling can work with encapsulations of go binaries (zip/tar) and instead need access to the binary directly. Since goreleaser manages creating the binary it is possible to expose the original binary to SBOM tooling even though it may be an encapsulation that is attached to a release (e.g. a zip or tar.gz)

Take for example using `cyclonedx-gomod app`:

```
#.goreleaser.yaml

archives:
  - format: tar.gz

sboms:
  - documents:
      - "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.cdx.sbom"
    artifacts: binary
    cmd: cyclonedx-gomod
    env: 
      - GOOS={{ .Os }}
      - GOARCH={{ .Arch }}
    args: ["app",  "-json", "-output", "$document", "$PWD"]

```

With `artifacts: binary` the SBOM tool gets access to the unarchived binary even though `archives[0].format = tar.gz` and there is **_no_** `archives[].format = binary` set. Additionally this uses goreleaser-sourced variables to seed the correct go-specific environment variables that the tool keys in on.

Relevant links:
- Support additional SBOM files based on CycloneDX: https://github.com/goreleaser/goreleaser/issues/2808
- Removal of binary from the SBOM pipe artifact filter: https://github.com/goreleaser/goreleaser/pull/2798

caarlos0: Thanks for the PR, its looking good to me overall! 

The only pointers I have are:

- the deduplication thing maybe is something worth moving to `artifacts.go`
- there is a test failing
- it looks like its missing a `go mod tidy`

other than that, lgtm!

PS: sorry for the delay reviewing this, was focusing on other things.
Thanks!
wagoodman: @caarlos0 no worries -- this one fell off my radar too.

I went ahead and moved the filter function to `artifacts.go`, fixed the failing tests, rebased + go mod tidied. Should be good to go 👍 
codecov[bot]: # [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) Report
> Merging [#2839](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (9395e02) into [main](https://codecov.io/gh/goreleaser/goreleaser/commit/0b3106b9f0b4b92c70a93bf46d8ca543eafb79d8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (0b3106b) will **increase** coverage by `0.05%`.
> The diff coverage is `85.71%`.

[![Impacted file tree graph](https://codecov.io/gh/goreleaser/goreleaser/pull/2839/graphs/tree.svg?width=650&height=150&src=pr&token=3bJvv6b7hN&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)

```diff
@@            Coverage Diff             @@
##             main    #2839      +/-   ##
==========================================
+ Coverage   84.49%   84.55%   +0.05%     
==========================================
  Files         111      111              
  Lines        8903     8953      +50     
==========================================
+ Hits         7523     7570      +47     
- Misses       1109     1111       +2     
- Partials      271      272       +1     
```


| [Impacted Files](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | Coverage Δ | |
|---|---|---|
| [internal/pipe/sbom/sbom.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2839/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9zYm9tL3Nib20uZ28=) | `85.52% <82.69%> (+0.52%)` | :arrow_up: |
| [internal/artifact/artifact.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2839/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvYXJ0aWZhY3QvYXJ0aWZhY3QuZ28=) | `98.02% <100.00%> (+0.18%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Last update [0b3106b...9395e02](https://codecov.io/gh/goreleaser/goreleaser/pull/2839?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).

wagoodman: @caarlos0 nudge --let me know if there is anything else you'd like changed/checked on this PR.

Patch:
diff --git a/go.mod b/go.mod
index ad3f7ef7..0322269a 100644
--- a/go.mod
+++ b/go.mod
@@ -28,6 +28,7 @@ require (
 	github.com/muesli/coral v1.0.0
 	github.com/muesli/mango-coral v1.0.1
 	github.com/muesli/roff v0.1.0
+	github.com/scylladb/go-set v1.0.2
 	github.com/slack-go/slack v0.10.2
 	github.com/stretchr/testify v1.7.0
 	github.com/ulikunitz/xz v0.5.10
diff --git a/go.sum b/go.sum
index 205e4719..a499f0d8 100644
--- a/go.sum
+++ b/go.sum
@@ -322,6 +322,8 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA=
+github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
@@ -674,6 +676,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/scylladb/go-set v1.0.2 h1:SkvlMCKhP0wyyct6j+0IHJkBkSZL+TDzZ4E7f7BCcRE=
+github.com/scylladb/go-set v1.0.2/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go
index 6050d5e8..04ea07ea 100644
--- a/internal/artifact/artifact.go
+++ b/internal/artifact/artifact.go
@@ -17,6 +17,7 @@ import (
 	"sync"
 
 	"github.com/apex/log"
+	"github.com/scylladb/go-set/strset"
 )
 
 // Type defines the type of an artifact.
@@ -379,6 +380,39 @@ func ByIDs(ids ...string) Filter {
 	return Or(filters...)
 }
 
+// ByBinaryLikeArtifacts filter artifacts down to artifacts that are Binary, UploadableBinary, or UniversalBinary,
+// deduplicating artifacts by path (preferring UploadableBinary over all others). Note: this filter is unique in the
+// sense that it cannot act in isolation of the state of other artifacts; the filter requires the whole list of
+// artifacts in advance to perform deduplication.
+func ByBinaryLikeArtifacts(arts Artifacts) Filter {
+	// find all of the paths for any uploadable binary artifacts
+	uploadableBins := arts.Filter(ByType(UploadableBinary)).List()
+	uploadableBinPaths := strset.New()
+	for _, a := range uploadableBins {
+		uploadableBinPaths.Add(a.Path)
+	}
+
+	// we want to keep any matching artifact that is not a binary that already has a path accounted for
+	// by another uploadable binary. We always prefer uploadable binary artifacts over binary artifacts.
+	deduplicateByPath := func(a *Artifact) bool {
+		if a.Type == UploadableBinary {
+			return true
+		}
+		return !uploadableBinPaths.Has(a.Path)
+	}
+
+	return And(
+		// allow all of the binary-like artifacts as possible...
+		Or(
+			ByType(Binary),
+			ByType(UploadableBinary),
+			ByType(UniversalBinary),
+		),
+		// ... but remove any duplicates found
+		deduplicateByPath,
+	)
+}
+
 // Or performs an OR between all given filters.
 func Or(filters ...Filter) Filter {
 	return func(a *Artifact) bool {
diff --git a/internal/pipe/sbom/sbom.go b/internal/pipe/sbom/sbom.go
index 70bb56d9..7653a2c3 100644
--- a/internal/pipe/sbom/sbom.go
+++ b/internal/pipe/sbom/sbom.go
@@ -36,43 +36,49 @@ func (Pipe) Default(ctx *context.Context) error {
 	ids := ids.New("sboms")
 	for i := range ctx.Config.SBOMs {
 		cfg := &ctx.Config.SBOMs[i]
-		if cfg.Cmd == "" {
-			cfg.Cmd = "syft"
+		if err := setConfigDefaults(cfg); err != nil {
+			return err
 		}
-		if cfg.Artifacts == "" {
-			cfg.Artifacts = "archive"
+		ids.Inc(cfg.ID)
+	}
+	return ids.Validate()
+}
+
+func setConfigDefaults(cfg *config.SBOM) error {
+	if cfg.Cmd == "" {
+		cfg.Cmd = "syft"
+	}
+	if cfg.Artifacts == "" {
+		cfg.Artifacts = "archive"
+	}
+	if len(cfg.Documents) == 0 {
+		switch cfg.Artifacts {
+		case "binary":
+			cfg.Documents = []string{"{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"}
+		case "any":
+			cfg.Documents = []string{}
+		default:
+			cfg.Documents = []string{"{{ .ArtifactName }}.sbom"}
 		}
-		if len(cfg.Documents) == 0 {
-			switch cfg.Artifacts {
-			case "binary":
-				cfg.Documents = []string{"{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.sbom"}
-			case "any":
-				cfg.Documents = []string{}
-			default:
-				cfg.Documents = []string{"{{ .ArtifactName }}.sbom"}
-			}
+	}
+	if cfg.Cmd == "syft" {
+		if len(cfg.Args) == 0 {
+			cfg.Args = []string{"$artifact", "--file", "$document", "--output", "spdx-json"}
 		}
-		if cfg.Cmd == "syft" {
-			if len(cfg.Args) == 0 {
-				cfg.Args = []string{"$artifact", "--file", "$document", "--output", "spdx-json"}
-			}
-			if len(cfg.Env) == 0 && cfg.Artifacts == "source" || cfg.Artifacts == "archive" {
-				cfg.Env = []string{
-					"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
-				}
+		if len(cfg.Env) == 0 && (cfg.Artifacts == "source" || cfg.Artifacts == "archive") {
+			cfg.Env = []string{
+				"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
 			}
 		}
-		if cfg.ID == "" {
-			cfg.ID = "default"
-		}
-
-		if cfg.Artifacts != "any" && len(cfg.Documents) > 1 {
-			return fmt.Errorf("multiple SBOM outputs when artifacts=%q is unsupported", cfg.Artifacts)
-		}
+	}
+	if cfg.ID == "" {
+		cfg.ID = "default"
+	}
 
-		ids.Inc(cfg.ID)
+	if cfg.Artifacts != "any" && len(cfg.Documents) > 1 {
+		return fmt.Errorf("multiple SBOM outputs when artifacts=%q is unsupported", cfg.Artifacts)
 	}
-	return ids.Validate()
+	return nil
 }
 
 // Run executes the Pipe.
@@ -96,7 +102,7 @@ func catalogTask(ctx *context.Context, cfg config.SBOM) func() error {
 		case "archive":
 			filters = append(filters, artifact.ByType(artifact.UploadableArchive))
 		case "binary":
-			filters = append(filters, artifact.ByType(artifact.UploadableBinary))
+			filters = append(filters, artifact.ByBinaryLikeArtifacts(ctx.Artifacts))
 		case "package":
 			filters = append(filters, artifact.ByType(artifact.LinuxPackage))
 		case "any":
@@ -152,49 +158,16 @@ func subprocessDistPath(distDir string, pathRelativeToCwd string) (string, error
 }
 
 func catalogArtifact(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact) ([]*artifact.Artifact, error) {
-	env := ctx.Env.Copy()
 	artifactDisplayName := "(any)"
-	templater := tmpl.New(ctx).WithEnv(env)
+	args, envs, paths, err := applyTemplate(ctx, cfg, a)
+	if err != nil {
+		return nil, fmt.Errorf("cataloging artifacts failed: %w", err)
+	}
 
 	if a != nil {
-		procPath, err := subprocessDistPath(ctx.Config.Dist, a.Path)
-		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts failed: cannot determine artifact path for %q: %w", a.Path, err)
-		}
-		env["artifact"] = procPath
-		env["artifactID"] = a.ID()
-
-		templater = templater.WithArtifact(a, nil)
 		artifactDisplayName = a.Path
 	}
 
-	var paths []string
-	for idx, sbom := range cfg.Documents {
-		input := filepath.Join(ctx.Config.Dist, expand(sbom, env))
-
-		path, err := templater.Apply(input)
-		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts failed: %s: invalid template: %w", input, err)
-		}
-
-		path, err = filepath.Abs(path)
-		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts failed: unable to create artifact path %q: %w", sbom, err)
-		}
-
-		procPath, err := subprocessDistPath(ctx.Config.Dist, path)
-		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts failed: cannot determine document path for %q: %w", path, err)
-		}
-
-		env[fmt.Sprintf("document%d", idx)] = procPath
-		if idx == 0 {
-			env["document"] = procPath
-		}
-
-		paths = append(paths, procPath)
-	}
-
 	var names []string
 	for _, p := range paths {
 		names = append(names, filepath.Base(p))
@@ -202,16 +175,6 @@ func catalogArtifact(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact
 
 	fields := log.Fields{"cmd": cfg.Cmd, "artifact": artifactDisplayName, "sboms": strings.Join(names, ", ")}
 
-	// nolint:prealloc
-	var args []string
-	for _, arg := range cfg.Args {
-		renderedArg, err := templater.Apply(expand(arg, env))
-		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts failed: %s: invalid template: %w", arg, err)
-		}
-		args = append(args, renderedArg)
-	}
-
 	// The GoASTScanner flags this as a security risk.
 	// However, this works as intended. The nosec annotation
 	// tells the scanner to ignore this.
@@ -223,7 +186,7 @@ func catalogArtifact(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact
 			cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value))
 		}
 	}
-	cmd.Env = append(cmd.Env, cfg.Env...)
+	cmd.Env = append(cmd.Env, envs...)
 	cmd.Dir = ctx.Config.Dist
 
 	var b bytes.Buffer
@@ -238,27 +201,19 @@ func catalogArtifact(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact
 
 	var artifacts []*artifact.Artifact
 
-	for _, sbom := range cfg.Documents {
-		templater = tmpl.New(ctx).WithEnv(env)
-		if a != nil {
-			env["artifact"] = a.Name
-			templater = templater.WithArtifact(a, nil)
+	for _, path := range paths {
+		if !filepath.IsAbs(path) {
+			path = filepath.Join(ctx.Config.Dist, path)
 		}
 
-		name, err := templater.Apply(expand(sbom, env))
+		matches, err := filepath.Glob(path)
 		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts failed: %s: invalid template: %w", a, err)
-		}
-
-		search := filepath.Join(ctx.Config.Dist, name)
-		matches, err := filepath.Glob(search)
-		if err != nil {
-			return nil, fmt.Errorf("cataloging artifacts: failed to find SBOM artifact %q: %w", search, err)
+			return nil, fmt.Errorf("cataloging artifacts: failed to find SBOM artifact %q: %w", path, err)
 		}
 		for _, match := range matches {
 			artifacts = append(artifacts, &artifact.Artifact{
 				Type: artifact.SBOM,
-				Name: name,
+				Name: filepath.Base(path),
 				Path: match,
 				Extra: map[string]interface{}{
 					artifact.ExtraID: cfg.ID,
@@ -271,6 +226,85 @@ func catalogArtifact(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact
 	return artifacts, nil
 }
 
+func applyTemplate(ctx *context.Context, cfg config.SBOM, a *artifact.Artifact) ([]string, []string, []string, error) {
+	env := ctx.Env.Copy()
+	var extraEnvs []string
+	templater := tmpl.New(ctx).WithEnv(env)
+
+	if a != nil {
+		procPath, err := subprocessDistPath(ctx.Config.Dist, a.Path)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("cataloging artifacts failed: cannot determine artifact path for %q: %w", a.Path, err)
+		}
+		extraEnvs = appendExtraEnv("artifact", procPath, extraEnvs, env)
+		extraEnvs = appendExtraEnv("artifactID", a.ID(), extraEnvs, env)
+
+		templater = templater.WithArtifact(a, nil)
+	}
+
+	for _, keyValue := range cfg.Env {
+		renderedKeyValue, err := templater.Apply(expand(keyValue, env))
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("env %q: invalid template: %w", keyValue, err)
+		}
+		extraEnvs = append(extraEnvs, renderedKeyValue)
+
+		fields := strings.Split(renderedKeyValue, "=")
+		key := fields[0]
+		renderedValue := strings.Join(fields[1:], "=")
+		env[key] = renderedValue
+	}
+
+	var paths []string
+	for idx, sbom := range cfg.Documents {
+		input := expand(sbom, env)
+		if !filepath.IsAbs(input) {
+			// assume any absolute path is handled correctly and assume that any relative path is not already
+			// adjusted to reference the dist path
+			input = filepath.Join(ctx.Config.Dist, input)
+		}
+
+		path, err := templater.Apply(input)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("input %q: invalid template: %w", input, err)
+		}
+
+		path, err = filepath.Abs(path)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("unable to create artifact path %q: %w", sbom, err)
+		}
+
+		procPath, err := subprocessDistPath(ctx.Config.Dist, path)
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("cannot determine document path for %q: %w", path, err)
+		}
+
+		extraEnvs = appendExtraEnv(fmt.Sprintf("document%d", idx), procPath, extraEnvs, env)
+		if idx == 0 {
+			extraEnvs = appendExtraEnv("document", procPath, extraEnvs, env)
+		}
+
+		paths = append(paths, procPath)
+	}
+
+	// nolint:prealloc
+	var args []string
+	for _, arg := range cfg.Args {
+		renderedArg, err := templater.Apply(expand(arg, env))
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("arg %q: invalid template: %w", arg, err)
+		}
+		args = append(args, renderedArg)
+	}
+
+	return args, extraEnvs, paths, nil
+}
+
+func appendExtraEnv(key, value string, envs []string, env map[string]string) []string {
+	env[key] = value
+	return append(envs, fmt.Sprintf("%s=%s", key, value))
+}
+
 func expand(s string, env map[string]string) string {
 	return os.Expand(s, func(key string) string {
 		return env[key]


Test patch:
diff --git a/internal/artifact/artifact_test.go b/internal/artifact/artifact_test.go
index 2a839203..bc20572e 100644
--- a/internal/artifact/artifact_test.go
+++ b/internal/artifact/artifact_test.go
@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/goreleaser/goreleaser/internal/golden"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/sync/errgroup"
 )
@@ -593,3 +594,259 @@ func TestMarshalJSON(t *testing.T) {
 	require.NoError(t, err)
 	golden.RequireEqualJSON(t, bts)
 }
+
+func Test_ByBinaryLikeArtifacts(t *testing.T) {
+	tests := []struct {
+		name     string
+		initial  []*Artifact
+		expected []*Artifact
+	}{
+		{
+			name: "keep all unique paths",
+			initial: []*Artifact{
+				{
+					Path: "binary-path",
+					Type: Binary,
+				},
+				{
+					Path: "uploadable-binary-path",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "universal-binary-path",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "binary-path",
+					Type: Binary,
+				},
+				{
+					Path: "uploadable-binary-path",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "universal-binary-path",
+					Type: UniversalBinary,
+				},
+			},
+		},
+		{
+			name: "duplicate path between binaries ignored (odd configuration)",
+			initial: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "uploadable-binary-path",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "uploadable-binary-path",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+		},
+		{
+			name: "remove duplicate binary",
+			initial: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "universal-binary-path",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "universal-binary-path",
+					Type: UniversalBinary,
+				},
+			},
+		},
+		{
+			name: "remove duplicate universal binary",
+			initial: []*Artifact{
+				{
+					Path: "binary-path",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "binary-path",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+			},
+		},
+		{
+			name: "remove multiple duplicates",
+			initial: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+			},
+		},
+		{
+			name: "keep duplicate uploadable binaries (odd configuration)",
+			initial: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UploadableBinary,
+				},
+			},
+		},
+		{
+			name: "keeps duplicates when there is no uploadable binary",
+			initial: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+			expected: []*Artifact{
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: Binary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+				{
+					Path: "!!!duplicate!!!",
+					Type: UniversalBinary,
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			arts := New()
+			for _, a := range tt.initial {
+				arts.Add(a)
+			}
+			actual := arts.Filter(ByBinaryLikeArtifacts(arts)).List()
+			assert.Equal(t, tt.expected, actual)
+
+			if t.Failed() {
+				t.Log("expected:")
+				for _, a := range tt.expected {
+					t.Logf("   %s: %s", a.Type.String(), a.Path)
+				}
+
+				t.Log("got:")
+				for _, a := range actual {
+					t.Logf("   %s: %s", a.Type.String(), a.Path)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/pipe/sbom/sbom_test.go b/internal/pipe/sbom/sbom_test.go
index f4fde14a..646f8a5a 100644
--- a/internal/pipe/sbom/sbom_test.go
+++ b/internal/pipe/sbom/sbom_test.go
@@ -5,6 +5,7 @@ import (
 	"os"
 	"path/filepath"
 	"sort"
+	"strings"
 	"testing"
 
 	"github.com/goreleaser/goreleaser/internal/artifact"
@@ -72,6 +73,23 @@ func TestSBOMCatalogDefault(t *testing.T) {
 				"SYFT_FILE_METADATA_CATALOGER_ENABLED=true",
 			},
 		},
+		{
+			configs: []config.SBOM{
+				{
+					Artifacts: "archive",
+					Env: []string{
+						"something=something-else",
+					},
+				},
+			},
+			artifact: "archive",
+			cmd:      defaultCmd,
+			sboms:    defaultSboms,
+			args:     defaultArgs,
+			env: []string{
+				"something=something-else",
+			},
+		},
 		{
 			configs: []config.SBOM{
 				{
@@ -224,7 +242,7 @@ func TestSBOMCatalogArtifacts(t *testing.T) {
 		},
 		{
 			desc:           "invalid args template",
-			expectedErrMsg: `cataloging artifacts failed: ${FOO}-{{ .foo }{{}}{: invalid template: template: tmpl:1: unexpected "}" in operand`,
+			expectedErrMsg: `cataloging artifacts failed: arg "${FOO}-{{ .foo }{{}}{": invalid template: template: tmpl:1: unexpected "}" in operand`,
 			ctx: context.New(
 				config.Project{
 					SBOMs: []config.SBOM{
@@ -287,9 +305,11 @@ func TestSBOMCatalogArtifacts(t *testing.T) {
 			),
 			sbomPaths: []string{
 				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
 			},
 			sbomNames: []string{
 				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
 			},
 		},
 		{
@@ -340,11 +360,13 @@ func TestSBOMCatalogArtifacts(t *testing.T) {
 				"artifact1.s2-ish.sbom",
 				"artifact2.s2-ish.sbom",
 				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
 			},
 			sbomNames: []string{
 				"artifact1.s2-ish.sbom",
 				"artifact2.s2-ish.sbom",
 				"artifact3-name_1.2.2_linux_amd64.sbom",
+				"artifact4-name_1.2.2_linux_amd64.sbom",
 			},
 		},
 		{
@@ -392,9 +414,11 @@ func TestSBOMCatalogArtifacts(t *testing.T) {
 			),
 			sbomPaths: []string{
 				"artifact3-name.test-user-name.sbom",
+				"artifact4.test-user-name.sbom",
 			},
 			sbomNames: []string{
 				"artifact3-name.test-user-name.sbom",
+				"artifact4.test-user-name.sbom",
 			},
 		},
 		{
@@ -583,3 +607,170 @@ func Test_subprocessDistPath(t *testing.T) {
 		})
 	}
 }
+
+func Test_templateNames(t *testing.T) {
+	art := artifact.Artifact{
+		Name:   "name-it",
+		Path:   "to/a/place",
+		Goos:   "darwin",
+		Goarch: "amd64",
+		Type:   artifact.Binary,
+		Extra: map[string]interface{}{
+			artifact.ExtraID: "id-it",
+			"Binary":         "binary-name",
+		},
+	}
+
+	wd, err := os.Getwd()
+	require.NoError(t, err)
+
+	tests := []struct {
+		name           string
+		dist           string
+		version        string
+		cfg            config.SBOM
+		artifact       artifact.Artifact
+		expectedValues map[string]string
+		expectedPaths  []string
+	}{
+		{
+			name:     "default configuration",
+			artifact: art,
+			cfg:      config.SBOM{},
+			dist:     "/somewhere/to/dist",
+			expectedPaths: []string{
+				"/somewhere/to/dist/name-it.sbom",
+			},
+			expectedValues: map[string]string{
+				"artifact":   "to/a/place",
+				"artifactID": "id-it",
+				"document":   "/somewhere/to/dist/name-it.sbom",
+				"document0":  "/somewhere/to/dist/name-it.sbom",
+			},
+		},
+		{
+			name:     "default configuration + relative dist",
+			artifact: art,
+			cfg:      config.SBOM{},
+			dist:     "somewhere/to/dist",
+			expectedPaths: []string{
+				filepath.Join(wd, "somewhere/to/dist/name-it.sbom"),
+			},
+			expectedValues: map[string]string{
+				"artifact":   "to/a/place", // note: this is always relative to ${dist}
+				"artifactID": "id-it",
+				"document":   filepath.Join(wd, "somewhere/to/dist/name-it.sbom"),
+				"document0":  filepath.Join(wd, "somewhere/to/dist/name-it.sbom"),
+			},
+		},
+		{
+			name: "custom document using $artifact",
+			// note: this configuration is probably a misconfiguration since it is placing SBOMs within each bin
+			// directory, however, it will behave as correctly as possible.
+			artifact: art,
+			cfg: config.SBOM{
+				Documents: []string{
+					// note: the artifact name is probably an incorrect value here since it can't express all attributes
+					// of the binary (os, arch, etc), so builds with multiple architectures will create SBOMs with the
+					// same name.
+					"${artifact}.cdx.sbom",
+				},
+			},
+			dist: "somewhere/to/dist",
+			expectedPaths: []string{
+				filepath.Join(wd, "somewhere/to/dist/to/a/place.cdx.sbom"),
+			},
+			expectedValues: map[string]string{
+				"artifact":   "to/a/place",
+				"artifactID": "id-it",
+				"document":   filepath.Join(wd, "somewhere/to/dist/to/a/place.cdx.sbom"),
+				"document0":  filepath.Join(wd, "somewhere/to/dist/to/a/place.cdx.sbom"),
+			},
+		},
+		{
+			name:     "custom document using build vars",
+			artifact: art,
+			cfg: config.SBOM{
+				Documents: []string{
+					"{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.cdx.sbom",
+				},
+			},
+			version: "1.0.0",
+			dist:    "somewhere/to/dist",
+			expectedPaths: []string{
+				filepath.Join(wd, "somewhere/to/dist/binary-name_1.0.0_darwin_amd64.cdx.sbom"),
+			},
+			expectedValues: map[string]string{
+				"artifact":   "to/a/place",
+				"artifactID": "id-it",
+				"document":   filepath.Join(wd, "somewhere/to/dist/binary-name_1.0.0_darwin_amd64.cdx.sbom"),
+				"document0":  filepath.Join(wd, "somewhere/to/dist/binary-name_1.0.0_darwin_amd64.cdx.sbom"),
+			},
+		},
+		{
+			name:     "env vars with go templated options",
+			artifact: art,
+			cfg: config.SBOM{
+				Documents: []string{
+					"{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.cdx.sbom",
+				},
+				Env: []string{
+					"with-env-var=value",
+					"custom-os={{ .Os }}-unique",
+					"custom-arch={{ .Arch }}-unique",
+				},
+			},
+			version: "1.0.0",
+			dist:    "somewhere/to/dist",
+			expectedPaths: []string{
+				filepath.Join(wd, "somewhere/to/dist/binary-name_1.0.0_darwin_amd64.cdx.sbom"),
+			},
+			expectedValues: map[string]string{
+				"artifact":     "to/a/place",
+				"artifactID":   "id-it",
+				"with-env-var": "value",
+				"custom-os":    "darwin-unique",
+				"custom-arch":  "amd64-unique",
+				"document":     filepath.Join(wd, "somewhere/to/dist/binary-name_1.0.0_darwin_amd64.cdx.sbom"),
+				"document0":    filepath.Join(wd, "somewhere/to/dist/binary-name_1.0.0_darwin_amd64.cdx.sbom"),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx := context.New(config.Project{
+				Dist: tt.dist,
+			})
+			ctx.Version = tt.version
+
+			cfg := tt.cfg
+			require.NoError(t, setConfigDefaults(&cfg))
+
+			var inputArgs []string
+			var expectedArgs []string
+			for key, value := range tt.expectedValues {
+				inputArgs = append(inputArgs, fmt.Sprintf("${%s}", key))
+				expectedArgs = append(expectedArgs, value)
+			}
+			cfg.Args = inputArgs
+
+			actualArgs, actualEnvs, actualPaths, err := applyTemplate(ctx, cfg, &tt.artifact)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.expectedPaths, actualPaths, "paths differ")
+
+			assert.Equal(t, expectedArgs, actualArgs, "arguments differ")
+
+			actualEnv := make(map[string]string)
+			for _, str := range actualEnvs {
+				key := strings.Split(str, "=")[0]
+				value := strings.Join(strings.Split(str, "=")[1:], "=")
+				actualEnv[key] = value
+			}
+
+			for k, v := range tt.expectedValues {
+				assert.Equal(t, v, actualEnv[k])
+			}
+		})
+	}
+}