Repository: mesosphere/mindthegap
Pull request #866: feat: add support for OCI artifacts
Author: login: mhrabovcin
Labels: name: feature
Created: 2025-03-19T14:28:51Z | Merged: 2025-03-24T10:10:41Z



Diff:
diff --git a/README.md b/README.md
index 02e24f8b..3e63175a 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,14 @@ The output file will be a tarball that can be seeded into a registry,
 or that can be untarred and used as the storage directory for an OCI registry
 served via `registry:2`.
 
+It is possible to include OCI artifacts that are not OCI images.
+This is useful for bundling Flux kustomizations, Helm Charts directly from OCI
+registries, and any arbitrary OCI artifacts. To include an OCI artifacts, specify
+the `--oci-artifacts-file` path. The format of the provided file matches the
+`--images-file` format. The `--platform` flag has no effect on OCI artifacts.
+
+The OCI artifacts with image index are not supported.
+
 #### Pushing an image bundle
 
 **_This command is deprecated - see [Pushing a bundle](#pushing-a-bundle-supports-both-image-or-helm-chart)_**
diff --git a/cmd/mindthegap/create/bundle/bundle.go b/cmd/mindthegap/create/bundle/bundle.go
index 5464ce65..f0e2f5e1 100644
--- a/cmd/mindthegap/create/bundle/bundle.go
+++ b/cmd/mindthegap/create/bundle/bundle.go
@@ -38,12 +38,13 @@ import (
 
 func NewCommand(out output.Output) *cobra.Command {
 	var (
-		imagesConfigFile     string
-		helmChartsConfigFile string
-		platforms            = flags.NewPlatformsValue("linux/amd64")
-		outputFile           string
-		overwrite            bool
-		imagePullConcurrency int
+		imagesConfigFile       string
+		helmChartsConfigFile   string
+		ociArtifactsConfigFile string
+		platforms              = flags.NewPlatformsValue("linux/amd64")
+		outputFile             string
+		overwrite              bool
+		imagePullConcurrency   int
 	)
 
 	cmd := &cobra.Command{
@@ -54,8 +55,9 @@ func NewCommand(out output.Output) *cobra.Command {
 		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			var (
-				helmChartsConfig config.HelmChartsConfig
-				imagesConfig     config.ImagesConfig
+				helmChartsConfig   config.HelmChartsConfig
+				imagesConfig       config.ImagesConfig
+				ociArtifactsConfig config.ImagesConfig
 			)
 			if imagesConfigFile != "" {
 				out.StartOperation("Parsing image bundle config")
@@ -81,6 +83,19 @@ func NewCommand(out output.Output) *cobra.Command {
 				helmChartsConfig = cfg
 			}
 
+			// for now, we start with re-using the same struct for OCI artifacts as for docker images.
+			if ociArtifactsConfigFile != "" {
+				out.StartOperation("Parsing OCI artifacts bundle config")
+				cfg, err := config.ParseImagesConfigFile(ociArtifactsConfigFile)
+				if err != nil {
+					out.EndOperationWithStatus(output.Failure())
+					return err
+				}
+				out.EndOperationWithStatus(output.Success())
+				out.V(4).Infof("OCI artifacts config: %+v", cfg)
+				ociArtifactsConfig = cfg
+			}
+
 			if !overwrite {
 				out.StartOperation("Checking if output file already exists")
 				_, err := os.Stat(outputFile)
@@ -142,9 +157,10 @@ func NewCommand(out output.Output) *cobra.Command {
 			logs.Debug.SetOutput(out.V(4).InfoWriter())
 			logs.Warn.SetOutput(out.V(2).InfoWriter())
 
-			if imagesConfigFile != "" {
-				if err := pullImages(
+			if imagesConfigFile != "" || ociArtifactsConfigFile != "" {
+				if err := pullImagesAndOCIArtifacts(
 					imagesConfig,
+					ociArtifactsConfig,
 					platforms,
 					imagePullConcurrency,
 					reg,
@@ -188,7 +204,10 @@ func NewCommand(out output.Output) *cobra.Command {
 		"File containing list of images to create bundle from, either as YAML configuration or a simple list of images")
 	cmd.Flags().StringVar(&helmChartsConfigFile, "helm-charts-file", "",
 		"YAML file containing configuration of Helm charts to create bundle from")
-	cmd.MarkFlagsOneRequired("images-file", "helm-charts-file")
+	cmd.Flags().StringVar(&ociArtifactsConfigFile, "oci-artifacts-file", "",
+		"File containing list of oci artifacts to create bundle from, "+
+			"either as YAML configuration or a simple list of images")
+	cmd.MarkFlagsOneRequired("images-file", "helm-charts-file", "oci-artifacts-file")
 	cmd.Flags().
 		Var(&platforms, "platform", "platforms to download images for (required format: <os>/<arch>[/<variant>])")
 	cmd.Flags().
@@ -201,13 +220,69 @@ func NewCommand(out output.Output) *cobra.Command {
 	return cmd
 }
 
-func pullImages(
-	cfg config.ImagesConfig,
+func pullImagesAndOCIArtifacts(
+	imagesConfig config.ImagesConfig,
+	ociArtifactsConfig config.ImagesConfig,
 	platforms flags.Platforms,
 	imagePullConcurrency int,
 	reg *registry.Registry,
 	outputDir string,
 	out output.Output,
+) error {
+	pullGauge := &output.ProgressGauge{}
+	pullGauge.SetCapacity(imagesConfig.TotalImages() + ociArtifactsConfig.TotalImages())
+	pullGauge.SetStatus("Pulling requested images")
+	out.StartOperationWithProgress(pullGauge)
+	progressFn := func() { pullGauge.Inc() }
+
+	if imagesConfig.TotalImages() > 0 {
+		if err := pullImages(
+			imagesConfig,
+			platforms,
+			imagePullConcurrency,
+			reg,
+			progressFn,
+			false,
+		); err != nil {
+			out.EndOperationWithStatus(output.Failure())
+			return err
+		}
+	}
+
+	if ociArtifactsConfig.TotalImages() > 0 {
+		if err := pullImages(
+			ociArtifactsConfig,
+			platforms,
+			imagePullConcurrency,
+			reg,
+			progressFn,
+			true,
+		); err != nil {
+			out.EndOperationWithStatus(output.Failure())
+			return err
+		}
+	}
+
+	if err := config.WriteSanitizedImagesConfigs(
+		filepath.Join(outputDir, "images.yaml"),
+		imagesConfig,
+		ociArtifactsConfig,
+	); err != nil {
+		out.EndOperationWithStatus(output.Failure())
+		return err
+	}
+
+	out.EndOperationWithStatus(output.Success())
+	return nil
+}
+
+func pullImages(
+	cfg config.ImagesConfig,
+	platforms flags.Platforms,
+	imagePullConcurrency int,
+	reg *registry.Registry,
+	progressFn func(),
+	isOCIArtifact bool,
 ) error {
 	// Sort registries for deterministic ordering.
 	regNames := cfg.SortedRegistryNames()
@@ -215,10 +290,6 @@ func pullImages(
 	eg, egCtx := errgroup.WithContext(context.Background())
 	eg.SetLimit(imagePullConcurrency)
 
-	pullGauge := &output.ProgressGauge{}
-	pullGauge.SetCapacity(cfg.TotalImages())
-	pullGauge.SetStatus("Pulling requested images")
-
 	destTLSRoundTripper, err := httputils.InsecureTLSRoundTripper(remote.DefaultTransport)
 	if err != nil {
 		return fmt.Errorf("error configuring TLS for destination registry: %w", err)
@@ -234,8 +305,6 @@ func pullImages(
 		remote.WithUserAgent(utils.Useragent()),
 	}
 
-	out.StartOperationWithProgress(pullGauge)
-
 	for registryIdx := range regNames {
 		registryName := regNames[registryIdx]
 
@@ -248,7 +317,6 @@ func pullImages(
 			"",
 		)
 		if err != nil {
-			out.EndOperationWithStatus(output.Failure())
 			return fmt.Errorf("error configuring TLS for source registry: %w", err)
 		}
 
@@ -290,16 +358,6 @@ func pullImages(
 						imageName,
 						imageTag,
 					)
-
-					imageIndex, err := images.ManifestListForImage(
-						srcImageName,
-						platformsStrings,
-						sourceRemoteOpts...,
-					)
-					if err != nil {
-						return err
-					}
-
 					destImageName := fmt.Sprintf(
 						"%s/%s:%s",
 						reg.Address(),
@@ -311,11 +369,28 @@ func pullImages(
 						return err
 					}
 
-					if err := remote.WriteIndex(ref, imageIndex, destRemoteOpts...); err != nil {
+					var image remote.Taggable
+					if isOCIArtifact {
+						image, err = images.OCIArtifactImage(
+							srcImageName,
+							sourceRemoteOpts...,
+						)
+					} else {
+						image, err = images.ManifestListForImage(
+							srcImageName,
+							platformsStrings,
+							sourceRemoteOpts...,
+						)
+					}
+					if err != nil {
+						return fmt.Errorf("failed to get image %q: %w", srcImageName, err)
+					}
+
+					if err := remote.Push(ref, image, destRemoteOpts...); err != nil {
 						return err
 					}
 
-					pullGauge.Inc()
+					progressFn()
 
 					return nil
 				})
@@ -332,13 +407,6 @@ func pullImages(
 	}
 
 	if err := eg.Wait(); err != nil {
-		out.EndOperationWithStatus(output.Failure())
-		return err
-	}
-
-	out.EndOperationWithStatus(output.Success())
-
-	if err := config.WriteSanitizedImagesConfig(cfg, filepath.Join(outputDir, "images.yaml")); err != nil {
 		return err
 	}
 
diff --git a/cmd/mindthegap/push/bundle/bundle.go b/cmd/mindthegap/push/bundle/bundle.go
index 43bf21e4..650b997f 100644
--- a/cmd/mindthegap/push/bundle/bundle.go
+++ b/cmd/mindthegap/push/bundle/bundle.go
@@ -432,19 +432,32 @@ func pushTag(
 	destRemoteOpts []remote.Option,
 	forceOCIMediaTypes bool,
 ) error {
-	idx, err := remote.Index(srcImage, sourceRemoteOpts...)
+	desc, err := remote.Get(srcImage, sourceRemoteOpts...)
 	if err != nil {
 		return err
 	}
 
-	if forceOCIMediaTypes {
-		idx, err = convertToOCIIndex(idx, srcImage, sourceRemoteOpts)
+	if desc.MediaType.IsIndex() {
+		idx, err := desc.ImageIndex()
 		if err != nil {
-			return fmt.Errorf("failed to convert index to OCI format: %w", err)
+			return err
 		}
+
+		if forceOCIMediaTypes {
+			idx, err = convertToOCIIndex(idx, srcImage, sourceRemoteOpts)
+			if err != nil {
+				return fmt.Errorf("failed to convert index to OCI format: %w", err)
+			}
+		}
+
+		return remote.WriteIndex(destImage, idx, destRemoteOpts...)
 	}
 
-	return remote.WriteIndex(destImage, idx, destRemoteOpts...)
+	image, err := desc.Image()
+	if err != nil {
+		return err
+	}
+	return remote.Write(destImage, image, destRemoteOpts...)
 }
 
 func pushOCIArtifacts(
diff --git a/config/images_config.go b/config/images_config.go
index 892a24ed..44c211fd 100644
--- a/config/images_config.go
+++ b/config/images_config.go
@@ -208,7 +208,13 @@ func ParseImagesConfigFile(configFile string) (ImagesConfig, error) {
 	return config, nil
 }
 
-func WriteSanitizedImagesConfig(cfg ImagesConfig, fileName string) error {
+func WriteSanitizedImagesConfigs(fileName string, cfgs ...ImagesConfig) error {
+	merged := &ImagesConfig{}
+	for _, cfg := range cfgs {
+		merged = merged.Merge(cfg)
+	}
+
+	cfg := *merged
 	for regName, regConfig := range cfg {
 		regConfig.Credentials = nil
 		regConfig.TLSVerify = nil
diff --git a/images/manifest.go b/images/manifest.go
index de8b0614..e8448f23 100644
--- a/images/manifest.go
+++ b/images/manifest.go
@@ -62,6 +62,55 @@ func ManifestListForImage(
 	}
 }
 
+// OCIArtifactImage gets the image from the registry and checks if the image is an OCI artifact.
+func OCIArtifactImage(
+	img string,
+	opts ...remote.Option,
+) (v1.Image, error) {
+	ref, err := name.ParseReference(img)
+	if err != nil {
+		return nil, fmt.Errorf("invalid OCI artifact reference %q: %w", img, err)
+	}
+
+	desc, err := remote.Get(ref, opts...)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to read OCI artifact descriptor for %q from registry: %w",
+			img,
+			err,
+		)
+	}
+
+	if desc.MediaType != types.OCIManifestSchema1 {
+		return nil, fmt.Errorf(
+			"unexpected media type in descriptor for OCI artifact %q", desc.MediaType,
+		)
+	}
+
+	image, err := desc.Image()
+	if err != nil {
+		return nil, fmt.Errorf("failed to read OCI artifact %q: %w", img, err)
+	}
+
+	manifest, err := image.Manifest()
+	if err != nil {
+		return nil, fmt.Errorf("failed to read OCI artifact manifest for %q: %w", img, err)
+	}
+
+	// The only official recommendation for OCI artifacts in the spec is to not use
+	// OCI image config media type for the manifest config.
+	// https://github.com/opencontainers/image-spec/blob/c05acf7eb327dae4704a4efe01253a0e60af6b34/artifacts-guidance.md
+	if manifest.Config.MediaType.IsConfig() {
+		return nil, fmt.Errorf(
+			"unsupported OCI artifact %q which has config with media type %q",
+			img,
+			manifest.Config.MediaType,
+		)
+	}
+
+	return image, nil
+}
+
 func retainOnlyRequestedPlatformsInIndex(
 	index v1.ImageIndex,
 	platforms ...string,
diff --git a/images/manifest_test.go b/images/manifest_test.go
index e892732a..1b250f69 100644
--- a/images/manifest_test.go
+++ b/images/manifest_test.go
@@ -450,3 +450,239 @@ func TestManifestListForImage_RemoteImage(t *testing.T) {
 		})
 	}
 }
+
+var (
+	gitOperatorIndexManifest = v1.IndexManifest{
+		Manifests: []v1.Descriptor{{
+			MediaType: types.OCIManifestSchema1,
+			Size:      406,
+			Digest: v1.Hash{
+				Algorithm: "sha256",
+				Hex:       "cf61dfcb070d4c71ca0bb94a2c91a4b84424b41fdc74f1a546f80c881ba5313a",
+			},
+			ArtifactType: "application/vnd.cncf.flux.config.v1+json",
+		}},
+		MediaType:     types.OCIImageIndex,
+		SchemaVersion: 2,
+	}
+	externalDNSImageManifest = v1.Manifest{
+		SchemaVersion: 2,
+		MediaType:     types.OCIManifestSchema1,
+		Config: v1.Descriptor{
+			MediaType: "application/vnd.cncf.helm.config.v1+json",
+			Size:      890,
+			Digest: v1.Hash{
+				Algorithm: "sha256",
+				Hex:       "98ef98c10c8bc4550eea6c96e14b209d625dc25cf0e97dea3e8224eae424f097",
+			},
+		},
+		Layers: []v1.Descriptor{{
+			MediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip",
+			Size:      63278,
+			Digest: v1.Hash{
+				Algorithm: "sha256",
+				Hex:       "655f8cf3e10558e093636eac5e1dbdeb413da8eae24691a32e04bb82058b0b84",
+			},
+			Annotations: map[string]string{
+				"org.opencontainers.image.title": "external-dns-7.5.6.tgz",
+			},
+		}},
+	}
+	podinfoFluxKustomizationManifest = v1.Manifest{
+		SchemaVersion: 2,
+		MediaType:     types.OCIManifestSchema1,
+		Config: v1.Descriptor{
+			MediaType: "application/vnd.cncf.flux.config.v1+json",
+			Size:      233,
+			Digest: v1.Hash{
+				Algorithm: "sha256",
+				Hex:       "0646cbad91cb1473e2abb80dddb5c41497f187ab095cab9f59fec863d428cab9",
+			},
+		},
+		Layers: []v1.Descriptor{
+			{
+				MediaType: "application/vnd.cncf.flux.content.v1.tar+gzip",
+				Size:      1113,
+				Digest: v1.Hash{
+					Algorithm: "sha256",
+					Hex:       "f0a5371a156fe7c66d4312dee4f357e3c6783284fab239f723962de6c8df78d0",
+				},
+			},
+		},
+		Annotations: map[string]string{
+			"org.opencontainers.image.created":  "2025-03-11T09:31:44Z",
+			"org.opencontainers.image.revision": "6.8.0/b3396adb98a6a0f5eeedd1a600beaf5e954a1f28",
+			"org.opencontainers.image.source":   "https://github.com/stefanprodan/podinfo",
+		},
+	}
+	generalOCIArtifactManifest = v1.Manifest{
+		SchemaVersion: 2,
+		MediaType:     "application/vnd.oci.image.manifest.v1+json",
+		Config: v1.Descriptor{
+			MediaType: "application/vnd.oci.empty.v1+json",
+			Digest: v1.Hash{
+				Algorithm: "sha256",
+				Hex:       "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
+			},
+			Size: 2,
+			Data: []byte("e30="),
+		},
+		Layers: []v1.Descriptor{
+			{
+				MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+				Digest: v1.Hash{
+					Algorithm: "sha256",
+					Hex:       "07517841e1ceca8c6e06dc6270df17f9c5a131faa36b592fa8b7aa4755b330f1",
+				},
+				Size: 497097,
+				Annotations: map[string]string{
+					"io.deis.oras.content.digest":    "sha256:37a4d24994f788618183ddf9882fd32cdbd89b1844fb0b9234c12804843148c7",
+					"io.deis.oras.content.unpack":    "true",
+					"org.opencontainers.image.title": ".",
+				},
+			},
+		},
+		Annotations: map[string]string{
+			"org.opencontainers.image.created": "2025-03-04T08:45:12Z",
+		},
+	}
+	alpineImageManifest = v1.Manifest{
+		SchemaVersion: 2,
+		MediaType:     "application/vnd.oci.image.manifest.v1+json",
+		Config: v1.Descriptor{
+			MediaType: "application/vnd.oci.image.config.v1+json",
+			Digest:    v1.Hash{Algorithm: "sha256", Hex: "aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b"},
+			Size:      581,
+		},
+		Layers: []v1.Descriptor{
+			{
+				MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
+				Digest:    v1.Hash{Algorithm: "sha256", Hex: "f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870"},
+				Size:      3642247,
+			},
+		},
+		Annotations: map[string]string{
+			"com.docker.official-images.bashbrew.arch": "amd64",
+			"org.opencontainers.image.base.name":       "scratch",
+			"org.opencontainers.image.created":         "2025-02-14T03:28:36Z",
+			"org.opencontainers.image.revision":        "17fe3d1e2d2cbf54d745139eab749c252e35b883",
+			"org.opencontainers.image.source": "https://github.com/alpinelinux/docker-alpine.git" +
+				"#17fe3d1e2d2cbf54d745139eab749c252e35b883:x86_64",
+			"org.opencontainers.image.url":     "https://hub.docker.com/_/alpine",
+			"org.opencontainers.image.version": "3.21.3",
+		},
+	}
+)
+
+func TestManifestListForOCIArtifact(t *testing.T) {
+	t.Parallel()
+
+	type args struct {
+		img string
+	}
+
+	tests := []struct {
+		name         string
+		args         args
+		wantManifest v1.Manifest
+		wantErr      string
+	}{
+		{
+			name:    "valid oci image - not oci artifact",
+			args:    args{img: "mesosphere/git-operator:v0.13.7"},
+			wantErr: "unexpected media type in descriptor for OCI artifact",
+		},
+		{
+			name:    "valid oci image, invalid oci artifact",
+			args:    args{img: "mesosphere/kube-apiserver:v1.24.4_fips.0"},
+			wantErr: "unexpected media type in descriptor for OCI artifact",
+		},
+		{
+			name:    "valid oci image with image config",
+			args:    args{img: "library/alpine:v1-amd64"},
+			wantErr: "unsupported OCI artifact",
+		},
+		{
+			name:         "valid oci artifact - helm chart",
+			args:         args{img: "bitnamicharts/external-dns:7.5.6"},
+			wantManifest: externalDNSImageManifest,
+		},
+		{
+			name:         "valid oci artifact - flux kustomization",
+			args:         args{img: "stefanprodan/manifests/podinfo:6.8.0"},
+			wantManifest: podinfoFluxKustomizationManifest,
+		},
+		{
+			name:         "valid oci artifact - general oci artifact",
+			args:         args{img: "general/oci:1.0.0"},
+			wantManifest: generalOCIArtifactManifest,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			mux := http.NewServeMux()
+			mux.Handle("/v2/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusOK)
+			}))
+			mux.Handle(
+				"/v2/mesosphere/git-operator/manifests/v0.13.7",
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", string(types.OCIImageIndex))
+					json.NewEncoder(w).Encode(gitOperatorIndexManifest)
+				}),
+			)
+			mux.Handle(
+				"/v2/bitnamicharts/external-dns/manifests/7.5.6",
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", string(types.OCIManifestSchema1))
+					json.NewEncoder(w).Encode(externalDNSImageManifest)
+				}),
+			)
+			mux.Handle(
+				"/v2/stefanprodan/manifests/podinfo/manifests/6.8.0",
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", string(types.OCIManifestSchema1))
+					json.NewEncoder(w).Encode(podinfoFluxKustomizationManifest)
+				}),
+			)
+			mux.Handle(
+				"/v2/general/oci/manifests/1.0.0",
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", string(types.OCIManifestSchema1))
+					json.NewEncoder(w).Encode(generalOCIArtifactManifest)
+				}),
+			)
+			mux.Handle(
+				"/v2/mesosphere/kube-apiserver/manifests/v1.24.4_fips.0",
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", string(types.DockerManifestSchema2))
+					json.NewEncoder(w).Encode(fipsImageManifest)
+				}),
+			)
+			mux.Handle(
+				"/v2/library/alpine/manifests/v1-amd64",
+				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Type", string(types.OCIManifestSchema1))
+					json.NewEncoder(w).Encode(alpineImageManifest)
+				}),
+			)
+			svr := httptest.NewServer(mux)
+			defer svr.Close()
+			got, err := OCIArtifactImage(
+				fmt.Sprintf("%s/%s", svr.Listener.Addr(), tt.args.img),
+			)
+			if tt.wantErr != "" {
+				require.ErrorContains(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+				require.NotNil(t, got)
+				gotManifest, err := got.Manifest()
+				require.NoError(t, err)
+				assert.Equal(t, tt.wantManifest, *gotManifest)
+			}
+		})
+	}
+}
diff --git a/test/e2e/imagebundle/helpers/helpers.go b/test/e2e/imagebundle/helpers/helpers.go
index cd9b48a4..b4edee29 100644
--- a/test/e2e/imagebundle/helpers/helpers.go
+++ b/test/e2e/imagebundle/helpers/helpers.go
@@ -51,6 +51,30 @@ func CreateBundle(t ginkgo.GinkgoTInterface, bundleFile, cfgFile string, platfor
 	gomega.ExpectWithOffset(1, createBundleCmd.Execute()).To(gomega.Succeed())
 }
 
+func CreateBundleOCI(t ginkgo.GinkgoTInterface, bundleFile, cfgFile string) {
+	createBundleCmd := NewCommand(t, createbundle.NewCommand)
+	createBundleCmd.SetArgs([]string{
+		"--output-file", bundleFile,
+		"--oci-artifacts-file", cfgFile,
+	})
+	gomega.ExpectWithOffset(1, createBundleCmd.Execute()).To(gomega.Succeed())
+}
+
+func CreateBundleOCIAndImages(t ginkgo.GinkgoTInterface, bundleFile, ociArtifactsFiles string, imagesFile string, platforms ...string) {
+	platformFlags := make([]string, 0, len(platforms))
+	for _, p := range platforms {
+		platformFlags = append(platformFlags, "--platform", p)
+	}
+
+	createBundleCmd := NewCommand(t, createbundle.NewCommand)
+	createBundleCmd.SetArgs(append([]string{
+		"--output-file", bundleFile,
+		"--oci-artifacts-file", ociArtifactsFiles,
+		"--images-file", imagesFile,
+	}, platformFlags...))
+	gomega.ExpectWithOffset(1, createBundleCmd.Execute()).To(gomega.Succeed())
+}
+
 func NewCommand(
 	t ginkgo.GinkgoTInterface,
 	newFn func(out output.Output) *cobra.Command,
diff --git a/test/e2e/imagebundle/oci_artifacts_test.go b/test/e2e/imagebundle/oci_artifacts_test.go
new file mode 100644
index 00000000..e6d21599
--- /dev/null
+++ b/test/e2e/imagebundle/oci_artifacts_test.go
@@ -0,0 +1,296 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("OCI artifacts support", func() {
+	var (
+		bundleFile           string
+		cmd                  *cobra.Command
+		stopCh               chan struct{}
+		expectedOCIArtifacts = []string{
+			"stefanprodan/manifests/podinfo:6.8.0",
+			"stefanprodan/charts/podinfo:6.8.0",
+			"mesosphere/kommander-applications:v2.14.0",
+		}
+	)
+
+	BeforeEach(func() {
+		tmpDir := GinkgoT().TempDir()
+
+		bundleFile = filepath.Join(tmpDir, "image-bundle.tar")
+
+		cmd = helpers.NewCommand(GinkgoT(), func(out output.Output) *cobra.Command {
+			var c *cobra.Command
+			c, stopCh = servebundle.NewCommand(out, "bundle")
+			return c
+		})
+	})
+
+	It("supports various oci artifacts media types", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-port", strconv.Itoa(port),
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("bundles OCI artifacts with repositories prefix", func() {
+		ipAddr := helpers.GetFirstNonLoopbackIP(GinkgoT())
+
+		tempCertDir := GinkgoT().TempDir()
+		caCertFile, _, certFile, keyFile := helpers.GenerateCertificateAndKeyWithIPSAN(
+			GinkgoT(),
+			tempCertDir,
+			ipAddr,
+		)
+
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-address", ipAddr.String(),
+			"--listen-port", strconv.Itoa(port),
+			"--tls-cert-file", certFile,
+			"--tls-private-key-file", keyFile,
+			"--repositories-prefix", "/some/test/prefix",
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), ipAddr.String(), port)
+
+		testRoundTripper, err := httputils.TLSConfiguredRoundTripper(
+			remote.DefaultTransport,
+			net.JoinHostPort(ipAddr.String(), strconv.Itoa(port)),
+			false,
+			caCertFile,
+		)
+		Expect(err).NotTo(HaveOccurred())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("%s:%d%s/%s", ipAddr, port, "/some/test/prefix", imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref, remote.WithTransport(testRoundTripper))
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("failes to create bundle with OCI image in OCI artifacts list", func() {
+		imagesTxt := filepath.Join(GinkgoT().TempDir(), "oic-images.txt")
+		Expect(os.WriteFile(imagesTxt, []byte("stefanprodan/podinfo:6.8.0"), 0644)).To(Succeed())
+		createBundleCmd := helpers.NewCommand(GinkgoT(), createbundle.NewCommand)
+		createBundleCmd.SetArgs([]string{
+			"--output-file", bundleFile,
+			"--oci-artifacts-file", imagesTxt,
+		})
+		Expect(createBundleCmd.Execute()).To(MatchError(ContainSubstring("unexpected media type in descriptor for OCI artifact")))
+	})
+
+	It("pushes OCI artifacts from OCI artfacts only bundle to the registry", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		reg, err := registry.NewRegistry(registry.Config{
+			Storage: registry.FilesystemStorage(filepath.Join(GinkgoT().TempDir(), "registry")),
+			Host:    "127.0.0.1",
+			Port:    uint16(port),
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(
+				reg.ListenAndServe(
+					funcr.New(func(prefix, args string) {
+						log.Println(prefix, args)
+					}, funcr.Options{}),
+				),
+			).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		cmd := helpers.NewCommand(
+			GinkgoT(),
+			func(out output.Output) *cobra.Command { return pushbundle.NewCommand(out, "bundle") },
+		)
+		args := []string{
+			"--bundle", bundleFile,
+			"--to-registry", fmt.Sprintf("127.0.0.1:%d", port),
+		}
+		cmd.SetArgs(args)
+		Expect(cmd.Execute()).To(Succeed())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		Expect(reg.Shutdown(context.Background())).To((Succeed()))
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("pushes images from mixes bundle with OCI images and OCI artfacts to the registry", func() {
+		helpers.CreateBundleOCIAndImages(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+			filepath.Join("testdata", "create-success.yaml"),
+			"linux/"+runtime.GOARCH,
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		reg, err := registry.NewRegistry(registry.Config{
+			Storage: registry.FilesystemStorage(filepath.Join(GinkgoT().TempDir(), "registry")),
+			Host:    "127.0.0.1",
+			Port:    uint16(port),
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(
+				reg.ListenAndServe(
+					funcr.New(func(prefix, args string) {
+						log.Println(prefix, args)
+					}, funcr.Options{}),
+				),
+			).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		cmd := helpers.NewCommand(
+			GinkgoT(),
+			func(out output.Output) *cobra.Command { return pushbundle.NewCommand(out, "bundle") },
+		)
+		args := []string{
+			"--bundle", bundleFile,
+			"--to-registry", fmt.Sprintf("127.0.0.1:%d", port),
+		}
+		cmd.SetArgs(args)
+		Expect(cmd.Execute()).To(Succeed())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		helpers.ValidateImageIsAvailable(
+			GinkgoT(),
+			"127.0.0.1",
+			port,
+			"",
+			"stefanprodan/podinfo",
+			"6.2.0",
+			[]*v1.Platform{{
+				OS:           "linux",
+				Architecture: runtime.GOARCH,
+			}},
+			false,
+		)
+
+		Expect(reg.Shutdown(context.Background())).To((Succeed()))
+		Eventually(done).Should(BeClosed())
+	})
+})
diff --git a/test/e2e/imagebundle/testdata/create-success-oci.yaml b/test/e2e/imagebundle/testdata/create-success-oci.yaml
new file mode 100644
index 00000000..4a636789
--- /dev/null
+++ b/test/e2e/imagebundle/testdata/create-success-oci.yaml
@@ -0,0 +1,14 @@
+# Copyright 2021 D2iQ, Inc. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+ghcr.io:
+  images:
+    # flux kustomization
+    stefanprodan/manifests/podinfo:
+      - 6.8.0
+    # helm chart
+    stefanprodan/charts/podinfo:
+      - 6.8.0
+    # generic artifact created with oras cli
+    mesosphere/kommander-applications:
+      - v2.14.0


Reviews:
user: login: jimmidyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:16:10Z
user: login: mhrabovcin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:32:06Z
user: login: jimmidyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:32:45Z
user: login: jimmidyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:33:13Z
user: login: jimmidyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:33:41Z
user: login: mhrabovcin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:43:10Z
user: login: jimmidyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T18:06:21Z
user: login: mhrabovcin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T13:37:19Z
user: login: mhrabovcin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T13:37:37Z
user: login: jimmidyson
body: Tested locally too and works great! Thanks @mhrabovcin and @cwyl02!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T10:10:14Z
user: login: jimmidyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T10:10:36Z

Review comments:
diff_hunk: @@ -50,6 +50,14 @@ The output file will be a tarball that can be seeded into a registry,
 or that can be untarred and used as the storage directory for an OCI registry
 served via `registry:2`.
 
+It is possible to include OCI artifacts that are not OCI images.
+This is useful for bundling Flux kustomizations, Helm Charts directly from OCI
+registries, and any arbitrary OCI artifacts. To include an OCI artifacts, specify
+the `--oci-artifacts-file` path. The format of the provided file matches the
+`--images-file` format. The `--platform` flag has no effect on OCI artifacts.
+
+The OCI artifacts with image index are not supported.
path: README.md
user: login: jimmidyson
body: What does this mean?
created_at: 2025-03-20T16:01:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,7 +352,7 @@ func pullImages(
 						return err
 					}
 
-					if err := remote.WriteIndex(ref, imageIndex, destRemoteOpts...); err != nil {
+					if err := remote.Push(ref, image, destRemoteOpts...); err != nil {
path: cmd/mindthegap/create/bundle/bundle.go
user: login: jimmidyson
body: I'm unsure whether this change will write referenced images in indexes:

godoc for `WriteIndex`:

> WriteIndex pushes the provided ImageIndex to the specified image reference.
> WriteIndex will attempt to push all of the referenced manifests before
> attempting to push the ImageIndex, to retain referential integrity.

godoc for `Push`:

> Push uploads the given Taggable to the specified reference.

Are we sure that `Push` will handle multiplatform image indexes correctly?
created_at: 2025-03-20T16:05:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,222 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("Serve Image Bundle with OCI artifacts", func() {
path: test/e2e/imagebundle/serve_bundle_oci_test.go
user: login: jimmidyson
body: Please rename this file and describe input - it's doing more than just `Serve`.
created_at: 2025-03-20T16:15:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,7 +352,7 @@ func pullImages(
 						return err
 					}
 
-					if err := remote.WriteIndex(ref, imageIndex, destRemoteOpts...); err != nil {
+					if err := remote.Push(ref, image, destRemoteOpts...); err != nil {
path: cmd/mindthegap/create/bundle/bundle.go
user: login: mhrabovcin
body: The `WriteIndex` calls the `Push` with no parameters modifications - https://github.com/google/go-containerregistry/blob/v0.20.3/pkg/v1/remote/write.go#L648-L653

I can change the code to make explicit calls for ImageIndex and Image types.
created_at: 2025-03-20T16:32:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,222 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("Serve Image Bundle with OCI artifacts", func() {
+	var (
+		bundleFile           string
+		cmd                  *cobra.Command
+		stopCh               chan struct{}
+		expectedOCIArtifacts = []string{
+			"stefanprodan/manifests/podinfo:6.8.0",
+			"stefanprodan/charts/podinfo:6.8.0",
+			"mesosphere/kommander-applications:v2.14.0",
+		}
+	)
+
+	BeforeEach(func() {
+		tmpDir := GinkgoT().TempDir()
+
+		bundleFile = filepath.Join(tmpDir, "image-bundle.tar")
+
+		cmd = helpers.NewCommand(GinkgoT(), func(out output.Output) *cobra.Command {
+			var c *cobra.Command
+			c, stopCh = servebundle.NewCommand(out, "bundle")
+			return c
+		})
+	})
+
+	It("supports various oci artifacts media types", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-port", strconv.Itoa(port),
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("With repositories prefix", func() {
+		ipAddr := helpers.GetFirstNonLoopbackIP(GinkgoT())
+
+		tempCertDir := GinkgoT().TempDir()
+		caCertFile, _, certFile, keyFile := helpers.GenerateCertificateAndKeyWithIPSAN(
+			GinkgoT(),
+			tempCertDir,
+			ipAddr,
+		)
+
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-address", ipAddr.String(),
+			"--listen-port", strconv.Itoa(port),
+			"--tls-cert-file", certFile,
+			"--tls-private-key-file", keyFile,
+			"--repositories-prefix", "/some/test/prefix",
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), ipAddr.String(), port)
+
+		testRoundTripper, err := httputils.TLSConfiguredRoundTripper(
+			remote.DefaultTransport,
+			net.JoinHostPort(ipAddr.String(), strconv.Itoa(port)),
+			false,
+			caCertFile,
+		)
+		Expect(err).NotTo(HaveOccurred())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("%s:%d%s/%s", ipAddr, port, "/some/test/prefix", imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref, remote.WithTransport(testRoundTripper))
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("failes to create bundle with OCI image in OCI artifacts list", func() {
+		imagesTxt := filepath.Join(GinkgoT().TempDir(), "oic-images.txt")
+		Expect(os.WriteFile(imagesTxt, []byte("stefanprodan/podinfo:6.8.0"), 0644)).To(Succeed())
+		createBundleCmd := helpers.NewCommand(GinkgoT(), createbundle.NewCommand)
+		createBundleCmd.SetArgs([]string{
+			"--output-file", bundleFile,
+			"--oci-artifacts-file", imagesTxt,
+		})
+		Expect(createBundleCmd.Execute()).To(MatchError(ContainSubstring("unexpected media type in descriptor for OCI artifact")))
+	})
+
+	It("pushes OCI artifacts to the registry", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		reg, err := registry.NewRegistry(registry.Config{
+			Storage: registry.FilesystemStorage(filepath.Join(GinkgoT().TempDir(), "registry")),
+			Host:    "127.0.0.1",
+			Port:    uint16(port),
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(
+				reg.ListenAndServe(
+					funcr.New(func(prefix, args string) {
+						log.Println(prefix, args)
+					}, funcr.Options{}),
+				),
+			).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		cmd := helpers.NewCommand(
+			GinkgoT(),
+			func(out output.Output) *cobra.Command { return pushbundle.NewCommand(out, "bundle") },
+		)
+		args := []string{
+			"--bundle", bundleFile,
+			"--to-registry", fmt.Sprintf("127.0.0.1:%d", port),
+		}
+		cmd.SetArgs(args)
+		Expect(cmd.Execute()).To(Succeed())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
path: test/e2e/imagebundle/serve_bundle_oci_test.go
user: login: jimmidyson
body: While this works, it has broken pushing images and OCI artifacts right now, tested locally.

We will need to update `push/bundle/bundle.go` too here to handle both images config files.
created_at: 2025-03-20T16:32:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,222 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("Serve Image Bundle with OCI artifacts", func() {
+	var (
+		bundleFile           string
+		cmd                  *cobra.Command
+		stopCh               chan struct{}
+		expectedOCIArtifacts = []string{
+			"stefanprodan/manifests/podinfo:6.8.0",
+			"stefanprodan/charts/podinfo:6.8.0",
+			"mesosphere/kommander-applications:v2.14.0",
+		}
+	)
+
+	BeforeEach(func() {
+		tmpDir := GinkgoT().TempDir()
+
+		bundleFile = filepath.Join(tmpDir, "image-bundle.tar")
+
+		cmd = helpers.NewCommand(GinkgoT(), func(out output.Output) *cobra.Command {
+			var c *cobra.Command
+			c, stopCh = servebundle.NewCommand(out, "bundle")
+			return c
+		})
+	})
+
+	It("supports various oci artifacts media types", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-port", strconv.Itoa(port),
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("With repositories prefix", func() {
+		ipAddr := helpers.GetFirstNonLoopbackIP(GinkgoT())
+
+		tempCertDir := GinkgoT().TempDir()
+		caCertFile, _, certFile, keyFile := helpers.GenerateCertificateAndKeyWithIPSAN(
+			GinkgoT(),
+			tempCertDir,
+			ipAddr,
+		)
+
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-address", ipAddr.String(),
+			"--listen-port", strconv.Itoa(port),
+			"--tls-cert-file", certFile,
+			"--tls-private-key-file", keyFile,
+			"--repositories-prefix", "/some/test/prefix",
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), ipAddr.String(), port)
+
+		testRoundTripper, err := httputils.TLSConfiguredRoundTripper(
+			remote.DefaultTransport,
+			net.JoinHostPort(ipAddr.String(), strconv.Itoa(port)),
+			false,
+			caCertFile,
+		)
+		Expect(err).NotTo(HaveOccurred())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("%s:%d%s/%s", ipAddr, port, "/some/test/prefix", imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref, remote.WithTransport(testRoundTripper))
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("failes to create bundle with OCI image in OCI artifacts list", func() {
+		imagesTxt := filepath.Join(GinkgoT().TempDir(), "oic-images.txt")
+		Expect(os.WriteFile(imagesTxt, []byte("stefanprodan/podinfo:6.8.0"), 0644)).To(Succeed())
+		createBundleCmd := helpers.NewCommand(GinkgoT(), createbundle.NewCommand)
+		createBundleCmd.SetArgs([]string{
+			"--output-file", bundleFile,
+			"--oci-artifacts-file", imagesTxt,
+		})
+		Expect(createBundleCmd.Execute()).To(MatchError(ContainSubstring("unexpected media type in descriptor for OCI artifact")))
+	})
+
+	It("pushes OCI artifacts to the registry", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		reg, err := registry.NewRegistry(registry.Config{
+			Storage: registry.FilesystemStorage(filepath.Join(GinkgoT().TempDir(), "registry")),
+			Host:    "127.0.0.1",
+			Port:    uint16(port),
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(
+				reg.ListenAndServe(
+					funcr.New(func(prefix, args string) {
+						log.Println(prefix, args)
+					}, funcr.Options{}),
+				),
+			).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		cmd := helpers.NewCommand(
+			GinkgoT(),
+			func(out output.Output) *cobra.Command { return pushbundle.NewCommand(out, "bundle") },
+		)
+		args := []string{
+			"--bundle", bundleFile,
+			"--to-registry", fmt.Sprintf("127.0.0.1:%d", port),
+		}
+		cmd.SetArgs(args)
+		Expect(cmd.Execute()).To(Succeed())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
path: test/e2e/imagebundle/serve_bundle_oci_test.go
user: login: jimmidyson
body: Tested locally and this is definitely broken btw - please add an e2e that combines both OCI and images configs.
created_at: 2025-03-20T16:33:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,7 +352,7 @@ func pullImages(
 						return err
 					}
 
-					if err := remote.WriteIndex(ref, imageIndex, destRemoteOpts...); err != nil {
+					if err := remote.Push(ref, image, destRemoteOpts...); err != nil {
path: cmd/mindthegap/create/bundle/bundle.go
user: login: jimmidyson
body: OK it seems to be working - I'm not sure how but I can look at that separately.
created_at: 2025-03-20T16:33:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +50,14 @@ The output file will be a tarball that can be seeded into a registry,
 or that can be untarred and used as the storage directory for an OCI registry
 served via `registry:2`.
 
+It is possible to include OCI artifacts that are not OCI images.
+This is useful for bundling Flux kustomizations, Helm Charts directly from OCI
+registries, and any arbitrary OCI artifacts. To include an OCI artifacts, specify
+the `--oci-artifacts-file` path. The format of the provided file matches the
+`--images-file` format. The `--platform` flag has no effect on OCI artifacts.
+
+The OCI artifacts with image index are not supported.
path: README.md
user: login: mhrabovcin
body: A reference (tag, sha) can be pointing to a Index or a Manifest https://oras.land/docs/concepts/artifact/#the-anatomy-of-an-oci-artifact. This patch limits the support only for refs pointing to OCI Artifact without an ImageIndex. I don't know how to validate if an image index would be an OCI artifact or an OCI image. 
created_at: 2025-03-20T16:43:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,7 +352,7 @@ func pullImages(
 						return err
 					}
 
-					if err := remote.WriteIndex(ref, imageIndex, destRemoteOpts...); err != nil {
+					if err := remote.Push(ref, image, destRemoteOpts...); err != nil {
path: cmd/mindthegap/create/bundle/bundle.go
user: login: jimmidyson
body: @mhrabovcin No need to change this - thanks for looking into the code!
created_at: 2025-03-20T18:06:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,222 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("Serve Image Bundle with OCI artifacts", func() {
+	var (
+		bundleFile           string
+		cmd                  *cobra.Command
+		stopCh               chan struct{}
+		expectedOCIArtifacts = []string{
+			"stefanprodan/manifests/podinfo:6.8.0",
+			"stefanprodan/charts/podinfo:6.8.0",
+			"mesosphere/kommander-applications:v2.14.0",
+		}
+	)
+
+	BeforeEach(func() {
+		tmpDir := GinkgoT().TempDir()
+
+		bundleFile = filepath.Join(tmpDir, "image-bundle.tar")
+
+		cmd = helpers.NewCommand(GinkgoT(), func(out output.Output) *cobra.Command {
+			var c *cobra.Command
+			c, stopCh = servebundle.NewCommand(out, "bundle")
+			return c
+		})
+	})
+
+	It("supports various oci artifacts media types", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-port", strconv.Itoa(port),
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("With repositories prefix", func() {
+		ipAddr := helpers.GetFirstNonLoopbackIP(GinkgoT())
+
+		tempCertDir := GinkgoT().TempDir()
+		caCertFile, _, certFile, keyFile := helpers.GenerateCertificateAndKeyWithIPSAN(
+			GinkgoT(),
+			tempCertDir,
+			ipAddr,
+		)
+
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-address", ipAddr.String(),
+			"--listen-port", strconv.Itoa(port),
+			"--tls-cert-file", certFile,
+			"--tls-private-key-file", keyFile,
+			"--repositories-prefix", "/some/test/prefix",
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), ipAddr.String(), port)
+
+		testRoundTripper, err := httputils.TLSConfiguredRoundTripper(
+			remote.DefaultTransport,
+			net.JoinHostPort(ipAddr.String(), strconv.Itoa(port)),
+			false,
+			caCertFile,
+		)
+		Expect(err).NotTo(HaveOccurred())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("%s:%d%s/%s", ipAddr, port, "/some/test/prefix", imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref, remote.WithTransport(testRoundTripper))
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("failes to create bundle with OCI image in OCI artifacts list", func() {
+		imagesTxt := filepath.Join(GinkgoT().TempDir(), "oic-images.txt")
+		Expect(os.WriteFile(imagesTxt, []byte("stefanprodan/podinfo:6.8.0"), 0644)).To(Succeed())
+		createBundleCmd := helpers.NewCommand(GinkgoT(), createbundle.NewCommand)
+		createBundleCmd.SetArgs([]string{
+			"--output-file", bundleFile,
+			"--oci-artifacts-file", imagesTxt,
+		})
+		Expect(createBundleCmd.Execute()).To(MatchError(ContainSubstring("unexpected media type in descriptor for OCI artifact")))
+	})
+
+	It("pushes OCI artifacts to the registry", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		reg, err := registry.NewRegistry(registry.Config{
+			Storage: registry.FilesystemStorage(filepath.Join(GinkgoT().TempDir(), "registry")),
+			Host:    "127.0.0.1",
+			Port:    uint16(port),
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(
+				reg.ListenAndServe(
+					funcr.New(func(prefix, args string) {
+						log.Println(prefix, args)
+					}, funcr.Options{}),
+				),
+			).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		cmd := helpers.NewCommand(
+			GinkgoT(),
+			func(out output.Output) *cobra.Command { return pushbundle.NewCommand(out, "bundle") },
+		)
+		args := []string{
+			"--bundle", bundleFile,
+			"--to-registry", fmt.Sprintf("127.0.0.1:%d", port),
+		}
+		cmd.SetArgs(args)
+		Expect(cmd.Execute()).To(Succeed())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
path: test/e2e/imagebundle/serve_bundle_oci_test.go
user: login: mhrabovcin
body: Thanks, I've restructured the code and use only single `images.yaml` file for both images and artifacts. Is that OK? I can easily restructure this to use a dedicated file for artifacts.
created_at: 2025-03-21T13:37:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,222 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("Serve Image Bundle with OCI artifacts", func() {
path: test/e2e/imagebundle/serve_bundle_oci_test.go
user: login: mhrabovcin
body: Changed the filename and updated test descriptions.
created_at: 2025-03-21T13:37:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,222 @@
+// Copyright 2021 D2iQ, Inc. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//go:build e2e
+
+package imagebundle_test
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/go-logr/logr/funcr"
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/phayes/freeport"
+	"github.com/spf13/cobra"
+
+	"github.com/mesosphere/dkp-cli-runtime/core/output"
+
+	createbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/create/bundle"
+	pushbundle "github.com/mesosphere/mindthegap/cmd/mindthegap/push/bundle"
+	servebundle "github.com/mesosphere/mindthegap/cmd/mindthegap/serve/bundle"
+	"github.com/mesosphere/mindthegap/docker/registry"
+	"github.com/mesosphere/mindthegap/images/httputils"
+	"github.com/mesosphere/mindthegap/test/e2e/imagebundle/helpers"
+)
+
+var _ = Describe("Serve Image Bundle with OCI artifacts", func() {
+	var (
+		bundleFile           string
+		cmd                  *cobra.Command
+		stopCh               chan struct{}
+		expectedOCIArtifacts = []string{
+			"stefanprodan/manifests/podinfo:6.8.0",
+			"stefanprodan/charts/podinfo:6.8.0",
+			"mesosphere/kommander-applications:v2.14.0",
+		}
+	)
+
+	BeforeEach(func() {
+		tmpDir := GinkgoT().TempDir()
+
+		bundleFile = filepath.Join(tmpDir, "image-bundle.tar")
+
+		cmd = helpers.NewCommand(GinkgoT(), func(out output.Output) *cobra.Command {
+			var c *cobra.Command
+			c, stopCh = servebundle.NewCommand(out, "bundle")
+			return c
+		})
+	})
+
+	It("supports various oci artifacts media types", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-port", strconv.Itoa(port),
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("With repositories prefix", func() {
+		ipAddr := helpers.GetFirstNonLoopbackIP(GinkgoT())
+
+		tempCertDir := GinkgoT().TempDir()
+		caCertFile, _, certFile, keyFile := helpers.GenerateCertificateAndKeyWithIPSAN(
+			GinkgoT(),
+			tempCertDir,
+			ipAddr,
+		)
+
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		cmd.SetArgs([]string{
+			"--bundle", bundleFile,
+			"--listen-address", ipAddr.String(),
+			"--listen-port", strconv.Itoa(port),
+			"--tls-cert-file", certFile,
+			"--tls-private-key-file", keyFile,
+			"--repositories-prefix", "/some/test/prefix",
+		})
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(cmd.Execute()).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), ipAddr.String(), port)
+
+		testRoundTripper, err := httputils.TLSConfiguredRoundTripper(
+			remote.DefaultTransport,
+			net.JoinHostPort(ipAddr.String(), strconv.Itoa(port)),
+			false,
+			caCertFile,
+		)
+		Expect(err).NotTo(HaveOccurred())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("%s:%d%s/%s", ipAddr, port, "/some/test/prefix", imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref, remote.WithTransport(testRoundTripper))
+			Expect(err).NotTo(HaveOccurred())
+		}
+
+		close(stopCh)
+
+		Eventually(done).Should(BeClosed())
+	})
+
+	It("failes to create bundle with OCI image in OCI artifacts list", func() {
+		imagesTxt := filepath.Join(GinkgoT().TempDir(), "oic-images.txt")
+		Expect(os.WriteFile(imagesTxt, []byte("stefanprodan/podinfo:6.8.0"), 0644)).To(Succeed())
+		createBundleCmd := helpers.NewCommand(GinkgoT(), createbundle.NewCommand)
+		createBundleCmd.SetArgs([]string{
+			"--output-file", bundleFile,
+			"--oci-artifacts-file", imagesTxt,
+		})
+		Expect(createBundleCmd.Execute()).To(MatchError(ContainSubstring("unexpected media type in descriptor for OCI artifact")))
+	})
+
+	It("pushes OCI artifacts to the registry", func() {
+		helpers.CreateBundleOCI(
+			GinkgoT(),
+			bundleFile,
+			filepath.Join("testdata", "create-success-oci.yaml"),
+		)
+
+		port, err := freeport.GetFreePort()
+		Expect(err).NotTo(HaveOccurred())
+		reg, err := registry.NewRegistry(registry.Config{
+			Storage: registry.FilesystemStorage(filepath.Join(GinkgoT().TempDir(), "registry")),
+			Host:    "127.0.0.1",
+			Port:    uint16(port),
+		})
+		Expect(err).NotTo(HaveOccurred())
+
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Expect(
+				reg.ListenAndServe(
+					funcr.New(func(prefix, args string) {
+						log.Println(prefix, args)
+					}, funcr.Options{}),
+				),
+			).To(Succeed())
+
+			close(done)
+		}()
+
+		helpers.WaitForTCPPort(GinkgoT(), "127.0.0.1", port)
+
+		cmd := helpers.NewCommand(
+			GinkgoT(),
+			func(out output.Output) *cobra.Command { return pushbundle.NewCommand(out, "bundle") },
+		)
+		args := []string{
+			"--bundle", bundleFile,
+			"--to-registry", fmt.Sprintf("127.0.0.1:%d", port),
+		}
+		cmd.SetArgs(args)
+		Expect(cmd.Execute()).To(Succeed())
+
+		for _, imageRef := range expectedOCIArtifacts {
+			mindthegapRef := fmt.Sprintf("127.0.0.1:%d/%s", port, imageRef)
+			ref, err := name.ParseReference(mindthegapRef)
+			Expect(err).NotTo(HaveOccurred())
+			_, err = remote.Image(ref)
+			Expect(err).NotTo(HaveOccurred())
+		}
path: test/e2e/imagebundle/serve_bundle_oci_test.go
user: login: jimmidyson
body: LGTM!
created_at: 2025-03-24T10:10:36Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-19T14:31:22Z
body: ## Unit test results
104 tests  +7   104 ✅ +7   0s ⏱️ ±0s
 26 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 427bca48. ± Comparison against base commit 4ac254d7.

[test-results]:data:application/gzip;base64,H4sIAC9m3WcC/13MSw7CIBSF4a00jB1QLgHqZppbHgmxLYbHyLh3ETWlDr/D5X8Q51ebyHUYLwNJxecGJqpMiZh92KtpZX3J7ZDyn+ZUtP6fbv7e/5gd+vU02BhD/C6x7EfzjXPysxzF5i7Y3Pd02DafKwhnctHIlUEhrLRKjTABmMkKpxWqhUkEAEfJ8wXeLzloBAEAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-19T14:50:36Z
body: ## e2e test results
41 tests  +5   38 ✅ +5   2m 11s ⏱️ -8s
 2 suites ±0    3 💤 ±0 
 1 files   ±0    0 ❌ ±0 

Results for commit 427bca48. ± Comparison against base commit 4ac254d7.

[test-results]:data:application/gzip;base64,H4sIAIZo3WcC/1WMywrDIBQFfyW47iLxipr+TDE+4NIkFh+r0H+v2mLS3Zk5MAdxuNpI7sN0G0jMmBrQAiYHldDv9YP6livVk3V4xKx1MSBP88RXNV04hWsRYxc2BB9+JuS9F+v+C37F2Wt8yTW+1rTfNkwFCKNi0YpJozi3wko5wQxgZsudlkouVCgAcCN5fwADEnzUAQEAAA==

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: jimmidyson
created_at: 2025-03-20T16:24:02Z
body: In testing, I see it echoing `Pulling requested images` multiple times - please update the output for OCI artifacts.
author_association: CONTRIBUTOR
user: login: mhrabovcin
created_at: 2025-03-21T13:30:14Z
body: > In testing, I see it echoing `Pulling requested images` multiple times - please update the output for OCI artifacts.

@jimmidyson I've restructured the code a bit and the image will only appear once if any OCI images or OCI artifacts are being pulled.

```
mindthegap create bundle --oci-artifacts-file ./test/e2e/imagebundle/testdata/create-success-oci.yaml --overwrite --images-file ./test/e2e/imagebundle/testdata/create-success.yaml
 ✓ Parsing image bundle config
 ✓ Parsing OCI artifacts bundle config
 ✓ Creating temporary directory
 ✓ Starting temporary Docker registry
 ✓ Pulling requested images [====================================>5/5] (time elapsed 03s)
 ✓ Archiving bundle to bundle.tar
```
author_association: CONTRIBUTOR