Repository: helm/helm (go)

Issue:
Feature: Publish OCI Annotations
Metadata in the form of annotations can be associated to [OCI based images](https://github.com/opencontainers/image-spec/blob/main/annotations.md). Helm also supports defining annotations on the `Chart.yaml` file. When publishing charts to OCI based registries, annotations should also be published to the image manifest.

Annotations would be associated with the following:

1. Known/Predefined keys
2. User defined annotations

The following table defines how OCI annotations are mapped including applicable fields from the `Chart.yaml` file.

| OCI Annotation                      | Description |  `Chart.yaml` field |
| ----------------------------------- | ------------| ------------------- |
| `org.opencontainers.image.created`  | RFC 3339 date the chart was published to the registry | |
| `org.opencontainers.image.authors` | Comma separated list of maintainers in the form `Maintainer Name 1 (email), Maintainer Name2 (email)` | `maintainers` |
| `org.opencontainers.image.url`| URL of the project | `home` |
| `org.opencontainers.image.source` | First item from the `sources` property | `sources` |
| `org.opencontainers.image.version` | Chart Version | `version` |
| `org.opencontainers.image.title` | Chart name | `name` |
| `org.opencontainers.image.description` | Chart description | `description` |
| `<User Defined Annotations>` | Additional annotations to include | `annotations` |

Additional annotations as designated in the `annotations` field within the `Chart.yaml` file.

Certain user defined annotations cannot override the generated value:

* `org.opencontainers.image.created`
* `org.opencontainers.image.title`
* `org.opencontainers.image.version`

Related to #11059

Pull request:
Attach annotations to OCI artifacts
Attaches standardized OCI image annotations and custom user provided annotations to OCI artifacts:

## OCI Annotations

The following OCI annotations are added automatically based on values in the `Chart.yaml` file

| OCI Annotation                      | Description |  `Chart.yaml` field |
| ----------------------------------- | ------------| ------------------- |
| `org.opencontainers.image.authors` | Comma separated list of maintainers in the form `Maintainer Name 1 (email), Maintainer Name 2 (email)` | `maintainers` |
| `org.opencontainers.image.url`| URL of the project | `home` |
| `org.opencontainers.image.source` | First item from the `sources` property | `sources` |
| `org.opencontainers.image.version` | Chart Version | `version` |
| `org.opencontainers.image.title` | Chart name | `name` |
| `org.opencontainers.image.description` | Chart description | `description` |

## User Defined Annotations

User defined annotations from the `Chart.yaml` `annotations` field are also mapped with the exception of `org.opencontainers.image.version` and `org.opencontainers.image.title` which will always use the computed values from the `Chart.yaml`

**If applicable**:
- [ ] this PR contains documentation
- [x] this PR contains unit tests
- [ ] this PR has been tested for backwards compatibility

Resolves #11062

Patch:
diff --git a/pkg/registry/client.go b/pkg/registry/client.go
index 2257f189c..f9569b62b 100644
--- a/pkg/registry/client.go
+++ b/pkg/registry/client.go
@@ -551,7 +551,9 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
 		descriptors = append(descriptors, provDescriptor)
 	}
 
-	manifestData, manifest, err := content.GenerateManifest(&configDescriptor, nil, descriptors...)
+	ociAnnotations := generateOCIAnnotations(meta)
+
+	manifestData, manifest, err := content.GenerateManifest(&configDescriptor, ociAnnotations, descriptors...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/registry/util.go b/pkg/registry/util.go
index 2a2754378..a4b90c80c 100644
--- a/pkg/registry/util.go
+++ b/pkg/registry/util.go
@@ -25,6 +25,7 @@ import (
 	"strings"
 
 	"github.com/Masterminds/semver/v3"
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 	orascontext "oras.land/oras-go/pkg/context"
@@ -35,6 +36,11 @@ import (
 	"helm.sh/helm/v3/pkg/chart/loader"
 )
 
+var immutableOciAnnotations = []string{
+	ocispec.AnnotationVersion,
+	ocispec.AnnotationTitle,
+}
+
 // IsOCI determines whether or not a URL is to be treated as an OCI URL
 func IsOCI(url string) bool {
 	return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme))
@@ -155,3 +161,80 @@ func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, i
 	}
 	return registryClient, nil
 }
+
+// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest
+func generateOCIAnnotations(meta *chart.Metadata) map[string]string {
+
+	// Get annotations from Chart attributes
+	ociAnnotations := generateChartOCIAnnotations(meta)
+
+	// Copy Chart annotations
+annotations:
+	for chartAnnotationKey, chartAnnotationValue := range meta.Annotations {
+
+		// Avoid overriding key properties
+		for _, immutableOciKey := range immutableOciAnnotations {
+			if immutableOciKey == chartAnnotationKey {
+				continue annotations
+			}
+		}
+
+		// Add chart annotation
+		ociAnnotations[chartAnnotationKey] = chartAnnotationValue
+	}
+
+	return ociAnnotations
+}
+
+// getChartOCIAnnotations will generate OCI annotations from the provided chart
+func generateChartOCIAnnotations(meta *chart.Metadata) map[string]string {
+	chartOCIAnnotations := map[string]string{}
+
+	chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description)
+	chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name)
+	chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version)
+	chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home)
+
+	if len(meta.Sources) > 0 {
+		chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0])
+	}
+
+	if meta.Maintainers != nil && len(meta.Maintainers) > 0 {
+		var maintainerSb strings.Builder
+
+		for maintainerIdx, maintainer := range meta.Maintainers {
+
+			if len(maintainer.Name) > 0 {
+				maintainerSb.WriteString(maintainer.Name)
+			}
+
+			if len(maintainer.Email) > 0 {
+				maintainerSb.WriteString(" (")
+				maintainerSb.WriteString(maintainer.Email)
+				maintainerSb.WriteString(")")
+			}
+
+			if maintainerIdx < len(meta.Maintainers)-1 {
+				maintainerSb.WriteString(", ")
+			}
+
+		}
+
+		chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String())
+
+	}
+
+	return chartOCIAnnotations
+}
+
+// addToMap takes an existing map and adds an item if the value is not empty
+func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string {
+
+	// Add item to map if its
+	if len(strings.TrimSpace(newValue)) > 0 {
+		inputMap[newKey] = newValue
+	}
+
+	return inputMap
+
+}


Test patch:
diff --git a/pkg/registry/util_test.go b/pkg/registry/util_test.go
new file mode 100644
index 000000000..3066257ad
--- /dev/null
+++ b/pkg/registry/util_test.go
@@ -0,0 +1,216 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry // import "helm.sh/helm/v3/pkg/registry"
+
+import (
+	"reflect"
+	"testing"
+
+	"helm.sh/helm/v3/pkg/chart"
+)
+
+func TestGenerateOCIChartAnnotations(t *testing.T) {
+
+	tests := []struct {
+		name   string
+		chart  *chart.Metadata
+		expect map[string]string
+	}{
+		{
+			"Baseline chart",
+			&chart.Metadata{
+				Name:    "oci",
+				Version: "0.0.1",
+			},
+			map[string]string{
+				"org.opencontainers.image.title":   "oci",
+				"org.opencontainers.image.version": "0.0.1",
+			},
+		},
+		{
+			"Simple chart values",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Home:        "https://helm.sh",
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"org.opencontainers.image.url":         "https://helm.sh",
+			},
+		},
+		{
+			"Maintainer without email",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Home:        "https://helm.sh",
+				Maintainers: []*chart.Maintainer{
+					{
+						Name: "John Snow",
+					},
+				},
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"org.opencontainers.image.url":         "https://helm.sh",
+				"org.opencontainers.image.authors":     "John Snow",
+			},
+		},
+		{
+			"Maintainer with email",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Home:        "https://helm.sh",
+				Maintainers: []*chart.Maintainer{
+					{Name: "John Snow", Email: "john@winterfell.com"},
+				},
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"org.opencontainers.image.url":         "https://helm.sh",
+				"org.opencontainers.image.authors":     "John Snow (john@winterfell.com)",
+			},
+		},
+		{
+			"Multiple Maintainers",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Home:        "https://helm.sh",
+				Maintainers: []*chart.Maintainer{
+					{Name: "John Snow", Email: "john@winterfell.com"},
+					{Name: "Jane Snow"},
+				},
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"org.opencontainers.image.url":         "https://helm.sh",
+				"org.opencontainers.image.authors":     "John Snow (john@winterfell.com), Jane Snow",
+			},
+		},
+		{
+			"Chart with Sources",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Sources: []string{
+					"https://github.com/helm/helm",
+				},
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"org.opencontainers.image.source":      "https://github.com/helm/helm",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+
+		result := generateChartOCIAnnotations(tt.chart)
+
+		if !reflect.DeepEqual(tt.expect, result) {
+			t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result)
+		}
+
+	}
+}
+
+func TestGenerateOCIAnnotations(t *testing.T) {
+
+	tests := []struct {
+		name   string
+		chart  *chart.Metadata
+		expect map[string]string
+	}{
+		{
+			"Baseline chart",
+			&chart.Metadata{
+				Name:    "oci",
+				Version: "0.0.1",
+			},
+			map[string]string{
+				"org.opencontainers.image.title":   "oci",
+				"org.opencontainers.image.version": "0.0.1",
+			},
+		},
+		{
+			"Simple chart values with custom Annotations",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Annotations: map[string]string{
+					"extrakey":   "extravlue",
+					"anotherkey": "anothervalue",
+				},
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"extrakey":                             "extravlue",
+				"anotherkey":                           "anothervalue",
+			},
+		},
+		{
+			"Verify Chart Name and Version cannot be overridden from annotations",
+			&chart.Metadata{
+				Name:        "oci",
+				Version:     "0.0.1",
+				Description: "OCI Helm Chart",
+				Annotations: map[string]string{
+					"org.opencontainers.image.title":   "badchartname",
+					"org.opencontainers.image.version": "1.0.0",
+					"extrakey":                         "extravlue",
+				},
+			},
+			map[string]string{
+				"org.opencontainers.image.title":       "oci",
+				"org.opencontainers.image.version":     "0.0.1",
+				"org.opencontainers.image.description": "OCI Helm Chart",
+				"extrakey":                             "extravlue",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+
+		result := generateOCIAnnotations(tt.chart)
+
+		if !reflect.DeepEqual(tt.expect, result) {
+			t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result)
+		}
+
+	}
+}
diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go
index aaabffde3..bdeacc712 100644
--- a/pkg/registry/utils_test.go
+++ b/pkg/registry/utils_test.go
@@ -279,12 +279,12 @@ func testPush(suite *TestSuite) {
 	suite.Equal(ref, result.Ref)
 	suite.Equal(meta.Name, result.Chart.Meta.Name)
 	suite.Equal(meta.Version, result.Chart.Meta.Version)
-	suite.Equal(int64(512), result.Manifest.Size)
+	suite.Equal(int64(684), result.Manifest.Size)
 	suite.Equal(int64(99), result.Config.Size)
 	suite.Equal(int64(973), result.Chart.Size)
 	suite.Equal(int64(695), result.Prov.Size)
 	suite.Equal(
-		"sha256:af4c20a1df1431495e673c14ecfa3a2ba24839a7784349d6787cd67957392e83",
+		"sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6",
 		result.Manifest.Digest)
 	suite.Equal(
 		"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580",
@@ -352,12 +352,12 @@ func testPull(suite *TestSuite) {
 	suite.Equal(ref, result.Ref)
 	suite.Equal(meta.Name, result.Chart.Meta.Name)
 	suite.Equal(meta.Version, result.Chart.Meta.Version)
-	suite.Equal(int64(512), result.Manifest.Size)
+	suite.Equal(int64(684), result.Manifest.Size)
 	suite.Equal(int64(99), result.Config.Size)
 	suite.Equal(int64(973), result.Chart.Size)
 	suite.Equal(int64(695), result.Prov.Size)
 	suite.Equal(
-		"sha256:af4c20a1df1431495e673c14ecfa3a2ba24839a7784349d6787cd67957392e83",
+		"sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6",
 		result.Manifest.Digest)
 	suite.Equal(
 		"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580",
@@ -368,7 +368,7 @@ func testPull(suite *TestSuite) {
 	suite.Equal(
 		"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256",
 		result.Prov.Digest)
-	suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}]}",
+	suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}",
 		string(result.Manifest.Data))
 	suite.Equal("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}",
 		string(result.Config.Data))