Repository: akuity/kargo
Pull request #1450 (2024-02-02 02:07:32)

fix!: play nicer with helm
Fixes https://github.com/akuity/kargo/issues/1281

May partially address https://github.com/akuity/kargo/issues/1280, although some aspects of that issue are strictly UI-related.

Supersedes #1294

Draft because I am still working out some kinks.

## Summary

With respect to Helm charts, the term "registry" has proven intuitive to people very familiar with OCI registries, but much more befuddling to those who are more familiar with classic (https/s-based) chart repositories. Confusion is compounded by Argo CD and Helm itself (e.g. in dependencies section of a `chart.yaml` file) continuing to exclusively use the classic repository parlance.

This PR implements a strategy refined through extensive discussion in #1294 and strikes "registry" from the Kargo vernacular.

* When subscribing a warehouse to a chart repo you:
    * Use URL + name for classic repos. (Classic repos can contain differently named charts, so the name field provides necessary qualification.)
    * Use URL only for oci repos. (OCI repo URLs imply the chart's name.)

* When defining promotion mechanisms:
    * Updates to a `chart.yaml` simply require you to match `repository` and `name` fields exactly as they appear in the `chart.yaml`. Kargo will figure out the rest.
    * Updates to an `Application` source require you to match the `repoURL` and `chart` fields exactly as they appear in the `Application` source.

Incidentally, this PR also changes some function names that contained the phrase "getLatestChart" to "selectChart." "Latest" implies something that isn't necessarily true since chart selection can be constrained with semver constraints.

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *docs-kargo-akuity-io* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | c8b4c06872ea0a47df37643173e829b5e58c264e |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/docs-kargo-akuity-io/deploys/65bc4e660dea200008b89a69 |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) Report
Attention: `37 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`397fc3e`)](https://app.codecov.io/gh/akuity/kargo/commit/397fc3e1ad7fab0818556ebe5d9254d4e6364b91?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) 48.77% compared to head [(`c8b4c06`)](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) 48.86%.

| [Files](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) | Patch % | Lines |
|---|---|---|
| [internal/helm/helm.go](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvaGVsbS9oZWxtLmdv) | 28.57% | [14 Missing and 1 partial :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/controller/warehouses/helm.go](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvY29udHJvbGxlci93YXJlaG91c2VzL2hlbG0uZ28=) | 55.17% | [11 Missing and 2 partials :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/controller/stages/health.go](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvY29udHJvbGxlci9zdGFnZXMvaGVhbHRoLmdv) | 53.33% | [5 Missing and 2 partials :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |
| [internal/api/query\_freights\_v1alpha1.go](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity#diff-aW50ZXJuYWwvYXBpL3F1ZXJ5X2ZyZWlnaHRzX3YxYWxwaGExLmdv) | 50.00% | [2 Missing :warning: ](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1450      +/-   ##
==========================================
+ Coverage   48.77%   48.86%   +0.08%     
==========================================
  Files         131      131              
  Lines       12026    12083      +57     
==========================================
+ Hits         5866     5904      +38     
- Misses       5959     5976      +17     
- Partials      201      203       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/akuity/kargo/pull/1450?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=akuity).


Patch:
diff --git a/api/v1alpha1/freight_types.go b/api/v1alpha1/freight_types.go
index ddb6ddc9..7c13bd3a 100644
--- a/api/v1alpha1/freight_types.go
+++ b/api/v1alpha1/freight_types.go
@@ -3,6 +3,7 @@ package v1alpha1
 import (
 	"crypto/sha1"
 	"fmt"
+	"path"
 	"sort"
 	"strings"
 
@@ -62,7 +63,12 @@ func (f *Freight) UpdateID() {
 	for _, chart := range f.Charts {
 		artifacts = append(
 			artifacts,
-			fmt.Sprintf("%s/%s:%s", chart.RegistryURL, chart.Name, chart.Version),
+			fmt.Sprintf(
+				"%s:%s",
+				// path.Join accounts for the possibility that chart.Name is empty
+				path.Join(chart.RepoURL, chart.Name),
+				chart.Version,
+			),
 		)
 	}
 	sort.Strings(artifacts)
diff --git a/api/v1alpha1/stage_types.go b/api/v1alpha1/stage_types.go
index 26401277..571a9637 100644
--- a/api/v1alpha1/stage_types.go
+++ b/api/v1alpha1/stage_types.go
@@ -345,14 +345,22 @@ type HelmImageUpdate struct {
 // HelmChartDependencyUpdate describes how a specific Helm chart that is used
 // as a subchart of an umbrella chart can be updated.
 type HelmChartDependencyUpdate struct {
-	// RegistryURL along with Name identify a subchart of the umbrella chart at
-	// ChartPath whose version should be updated.
+	// Repository along with Name identifies a subchart of the umbrella chart at
+	// ChartPath whose version should be updated. The values of both fields should
+	// exactly match the values of the fields of the same names in a dependency
+	// expressed in the Chart.yaml of the umbrella chart at ChartPath. i.e. Do not
+	// match the values of these two fields to your Warehouse; match them to the
+	// Chart.yaml. This is a required field.
 	//
 	//+kubebuilder:validation:MinLength=1
 	//+kubebuilder:validation:Pattern=`^(((https?)|(oci))://)([\w\d\.\-]+)(:[\d]+)?(/.*)*$`
-	RegistryURL string `json:"registryURL"`
-	// Name along with RegistryURL identify a subchart of the umbrella chart at
-	// ChartPath whose version should be updated.
+	Repository string `json:"repository"`
+	// Name along with Repository identifies a subchart of the umbrella chart at
+	// ChartPath whose version should be updated. The values of both fields should
+	// exactly match the values of the fields of the same names in a dependency
+	// expressed in the Chart.yaml of the umbrella chart at ChartPath. i.e. Do not
+	// match the values of these two fields to your Warehouse; match them to the
+	// Chart.yaml. This is a required field.
 	//
 	//+kubebuilder:validation:MinLength=1
 	Name string `json:"name"`
@@ -397,19 +405,24 @@ func (a *ArgoCDAppUpdate) AppNamespaceOrDefault() string {
 // ArgoCDSourceUpdate describes updates that should be applied to one of an Argo
 // CD Application resource's sources.
 type ArgoCDSourceUpdate struct {
-	// RepoURL identifies which of the Argo CD Application's sources this update
-	// is intended for. Note: As of Argo CD 2.6, Application's can use multiple
-	// sources.
+	// RepoURL along with the Chart field identifies which of an Argo CD
+	// Application's sources this update is intended for. Note: As of Argo CD 2.6,
+	// Applications can use multiple sources. When the source to be updated
+	// references a Helm chart repository, the values of the RepoURL and Chart
+	// fields should exactly match the values of the fields of the same names in
+	// the source. i.e. Do not match the values of these two fields to your
+	// Warehouse; match them to the Application source you wish to update. This is
+	// a required field.
 	//
 	//+kubebuilder:validation:MinLength=1
 	RepoURL string `json:"repoURL"`
-	// Chart specifies a chart within a Helm chart registry if RepoURL points to a
-	// Helm chart registry. Application sources that point directly at a chart do
-	// so through a combination of their own RepoURL (registry) and Chart fields,
-	// so BOTH of those are used as criteria in selecting an Application source to
-	// update. This field MUST always be used when RepoURL points at a Helm chart
-	// registry. This field MUST never be used when RepoURL points at a Git
-	// repository.
+	// Chart along with the RepoURL field identifies which of an Argo CD
+	// Application's sources this update is intended for. Note: As of Argo CD 2.6,
+	// Applications can use multiple sources. When the source to be updated
+	// references a Helm chart repository, the values of the RepoURL and Chart
+	// fields should exactly match the values of the fields of the same names in
+	// the source. i.e. Do not match the values of these two fields to your
+	// Warehouse; match them to the Application source you wish to update.
 	//
 	//+kubebuilder:validation:Optional
 	Chart string `json:"chart,omitempty"`
@@ -592,8 +605,13 @@ type Image struct {
 
 // Chart describes a specific version of a Helm chart.
 type Chart struct {
-	// RepoURL specifies the remote registry in which this chart is located.
-	RegistryURL string `json:"registryURL,omitempty"`
+	// RepoURL specifies the URL of a Helm chart repository. Classic chart
+	// repositories (using HTTP/S) can contain differently named charts. When this
+	// field points to such a repository, the Name field will specify the name of
+	// the chart within the repository. In the case of a repository within an OCI
+	// registry, the URL implicitly points to a specific chart and the Name field
+	// will be empty.
+	RepoURL string `json:"repoURL,omitempty"`
 	// Name specifies the name of the chart.
 	Name string `json:"name,omitempty"`
 	// Version specifies a particular version of the chart.
diff --git a/api/v1alpha1/types.proto b/api/v1alpha1/types.proto
index 457ce20e..278d9d06 100644
--- a/api/v1alpha1/types.proto
+++ b/api/v1alpha1/types.proto
@@ -44,13 +44,13 @@ message KargoRenderPromotionMechanism {
 }
 
 message Chart {
-  string registry_url = 1 [json_name = "registryURL"];
+  string repo_url = 1 [json_name = "repoURL"];
   string name = 2 [json_name = "name"];
   string version = 3 [json_name = "version"];
 }
 
 message ChartSubscription {
-  string registry_url = 1 [json_name = "registryURL"];
+  string repo_url = 1 [json_name = "repoURL"];
   optional string name = 2 [json_name = "name"];
   optional string semver_constraint = 3 [json_name = "semverConstraint"];
 }
@@ -109,7 +109,7 @@ message ArgoCDAppSyncStatus {
 }
 
 message HelmChartDependencyUpdate {
-  string registry_url = 1 [json_name = "registryURL"];
+  string repository = 1 [json_name = "repositoryURL"];
   string name = 2 [json_name = "name"];
   string chart_path = 3 [json_name = "chartPath"];
 }
diff --git a/api/v1alpha1/warehouse_types.go b/api/v1alpha1/warehouse_types.go
index 89183c98..ad65245e 100644
--- a/api/v1alpha1/warehouse_types.go
+++ b/api/v1alpha1/warehouse_types.go
@@ -180,17 +180,23 @@ type ImageSubscription struct {
 
 // ChartSubscription defines a subscription to a Helm chart repository.
 type ChartSubscription struct {
-	// RegistryURL specifies the URL of a Helm chart registry. It may be a classic
-	// chart registry (using HTTP/S) OR an OCI registry. This field is required.
+	// RepoURL specifies the URL of a Helm chart repository. It may be a classic
+	// chart repository (using HTTP/S) OR a repository within an OCI registry.
+	// Classic chart repositories can contain differently named charts. When this
+	// field points to such a repository, the Name field MUST also be used
+	// to specify the name of the desired chart within that repository. In the
+	// case of a repository within an OCI registry, the URL implicitly points to
+	// a specific chart and the Name field MUST NOT be used. The RepoURL field is
+	// required.
 	//
 	//+kubebuilder:validation:MinLength=1
 	//+kubebuilder:validation:Pattern=`^(((https?)|(oci))://)([\w\d\.\-]+)(:[\d]+)?(/.*)*$`
-	RegistryURL string `json:"registryURL"`
-	// Name specifies a Helm chart to subscribe to within the Helm chart registry
-	// specified by the RegistryURL field. This field is required.
-	//
-	//+kubebuilder:validation:MinLength=1
-	Name string `json:"name"`
+	RepoURL string `json:"repoURL"`
+	// Name specifies the name of a Helm chart to subscribe to within a classic
+	// chart repository specified by the RepoURL field. This field is required
+	// when the RepoURL field points to a classic chart repository and MUST
+	// otherwise be empty.
+	Name string `json:"name,omitempty"`
 	// SemverConstraint specifies constraints on what new chart versions are
 	// permissible. This field is optional. When left unspecified, there will be
 	// no constraints, which means the latest version of the chart will always be
diff --git a/charts/kargo/crds/kargo.akuity.io_freights.yaml b/charts/kargo/crds/kargo.akuity.io_freights.yaml
index 071524a8..0e084352 100644
--- a/charts/kargo/crds/kargo.akuity.io_freights.yaml
+++ b/charts/kargo/crds/kargo.akuity.io_freights.yaml
@@ -39,9 +39,13 @@ spec:
                 name:
                   description: Name specifies the name of the chart.
                   type: string
-                registryURL:
-                  description: RepoURL specifies the remote registry in which this
-                    chart is located.
+                repoURL:
+                  description: RepoURL specifies the URL of a Helm chart repository.
+                    Classic chart repositories (using HTTP/S) can contain differently
+                    named charts. When this field points to such a repository, the
+                    Name field will specify the name of the chart within the repository.
+                    In the case of a repository within an OCI registry, the URL implicitly
+                    points to a specific chart and the Name field will be empty.
                   type: string
                 version:
                   description: Version specifies a particular version of the chart.
diff --git a/charts/kargo/crds/kargo.akuity.io_stages.yaml b/charts/kargo/crds/kargo.akuity.io_stages.yaml
index 4643673c..ec8162a0 100644
--- a/charts/kargo/crds/kargo.akuity.io_stages.yaml
+++ b/charts/kargo/crds/kargo.akuity.io_stages.yaml
@@ -90,16 +90,16 @@ spec:
                               sources.
                             properties:
                               chart:
-                                description: Chart specifies a chart within a Helm
-                                  chart registry if RepoURL points to a Helm chart
-                                  registry. Application sources that point directly
-                                  at a chart do so through a combination of their
-                                  own RepoURL (registry) and Chart fields, so BOTH
-                                  of those are used as criteria in selecting an Application
-                                  source to update. This field MUST always be used
-                                  when RepoURL points at a Helm chart registry. This
-                                  field MUST never be used when RepoURL points at
-                                  a Git repository.
+                                description: 'Chart along with the RepoURL field identifies
+                                  which of an Argo CD Application''s sources this
+                                  update is intended for. Note: As of Argo CD 2.6,
+                                  Applications can use multiple sources. When the
+                                  source to be updated references a Helm chart repository,
+                                  the values of the RepoURL and Chart fields should
+                                  exactly match the values of the fields of the same
+                                  names in the source. i.e. Do not match the values
+                                  of these two fields to your Warehouse; match them
+                                  to the Application source you wish to update.'
                                 type: string
                               helm:
                                 description: Helm describes updates to the source's
@@ -189,10 +189,17 @@ spec:
                                 - images
                                 type: object
                               repoURL:
-                                description: 'RepoURL identifies which of the Argo
-                                  CD Application''s sources this update is intended
-                                  for. Note: As of Argo CD 2.6, Application''s can
-                                  use multiple sources.'
+                                description: 'RepoURL along with the Chart field identifies
+                                  which of an Argo CD Application''s sources this
+                                  update is intended for. Note: As of Argo CD 2.6,
+                                  Applications can use multiple sources. When the
+                                  source to be updated references a Helm chart repository,
+                                  the values of the RepoURL and Chart fields should
+                                  exactly match the values of the fields of the same
+                                  names in the source. i.e. Do not match the values
+                                  of these two fields to your Warehouse; match them
+                                  to the Application source you wish to update. This
+                                  is a required field.'
                                 minLength: 1
                                 type: string
                               updateTargetRevision:
@@ -240,22 +247,34 @@ spec:
                                     pattern: ^[\w-\.]+(/[\w-\.]+)*$
                                     type: string
                                   name:
-                                    description: Name along with RegistryURL identify
+                                    description: Name along with Repository identifies
                                       a subchart of the umbrella chart at ChartPath
-                                      whose version should be updated.
+                                      whose version should be updated. The values
+                                      of both fields should exactly match the values
+                                      of the fields of the same names in a dependency
+                                      expressed in the Chart.yaml of the umbrella
+                                      chart at ChartPath. i.e. Do not match the values
+                                      of these two fields to your Warehouse; match
+                                      them to the Chart.yaml. This is a required field.
                                     minLength: 1
                                     type: string
-                                  registryURL:
-                                    description: RegistryURL along with Name identify
+                                  repository:
+                                    description: Repository along with Name identifies
                                       a subchart of the umbrella chart at ChartPath
-                                      whose version should be updated.
+                                      whose version should be updated. The values
+                                      of both fields should exactly match the values
+                                      of the fields of the same names in a dependency
+                                      expressed in the Chart.yaml of the umbrella
+                                      chart at ChartPath. i.e. Do not match the values
+                                      of these two fields to your Warehouse; match
+                                      them to the Chart.yaml. This is a required field.
                                     minLength: 1
                                     pattern: ^(((https?)|(oci))://)([\w\d\.\-]+)(:[\d]+)?(/.*)*$
                                     type: string
                                 required:
                                 - chartPath
                                 - name
-                                - registryURL
+                                - repository
                                 type: object
                               type: array
                             images:
@@ -524,9 +543,14 @@ spec:
                         name:
                           description: Name specifies the name of the chart.
                           type: string
-                        registryURL:
-                          description: RepoURL specifies the remote registry in which
-                            this chart is located.
+                        repoURL:
+                          description: RepoURL specifies the URL of a Helm chart repository.
+                            Classic chart repositories (using HTTP/S) can contain
+                            differently named charts. When this field points to such
+                            a repository, the Name field will specify the name of
+                            the chart within the repository. In the case of a repository
+                            within an OCI registry, the URL implicitly points to a
+                            specific chart and the Name field will be empty.
                           type: string
                         version:
                           description: Version specifies a particular version of the
@@ -659,9 +683,15 @@ spec:
                             name:
                               description: Name specifies the name of the chart.
                               type: string
-                            registryURL:
-                              description: RepoURL specifies the remote registry in
-                                which this chart is located.
+                            repoURL:
+                              description: RepoURL specifies the URL of a Helm chart
+                                repository. Classic chart repositories (using HTTP/S)
+                                can contain differently named charts. When this field
+                                points to such a repository, the Name field will specify
+                                the name of the chart within the repository. In the
+                                case of a repository within an OCI registry, the URL
+                                implicitly points to a specific chart and the Name
+                                field will be empty.
                               type: string
                             version:
                               description: Version specifies a particular version
@@ -864,9 +894,15 @@ spec:
                           name:
                             description: Name specifies the name of the chart.
                             type: string
-                          registryURL:
-                            description: RepoURL specifies the remote registry in
-                              which this chart is located.
+                          repoURL:
+                            description: RepoURL specifies the URL of a Helm chart
+                              repository. Classic chart repositories (using HTTP/S)
+                              can contain differently named charts. When this field
+                              points to such a repository, the Name field will specify
+                              the name of the chart within the repository. In the
+                              case of a repository within an OCI registry, the URL
+                              implicitly points to a specific chart and the Name field
+                              will be empty.
                             type: string
                           version:
                             description: Version specifies a particular version of
diff --git a/charts/kargo/crds/kargo.akuity.io_warehouses.yaml b/charts/kargo/crds/kargo.akuity.io_warehouses.yaml
index 779a9010..abcd12e7 100644
--- a/charts/kargo/crds/kargo.akuity.io_warehouses.yaml
+++ b/charts/kargo/crds/kargo.akuity.io_warehouses.yaml
@@ -47,15 +47,23 @@ spec:
                         repository.
                       properties:
                         name:
-                          description: Name specifies a Helm chart to subscribe to
-                            within the Helm chart registry specified by the RegistryURL
-                            field. This field is required.
-                          minLength: 1
+                          description: Name specifies the name of a Helm chart to
+                            subscribe to within a classic chart repository specified
+                            by the RepoURL field. This field is required when the
+                            RepoURL field points to a classic chart repository and
+                            MUST otherwise be empty.
                           type: string
-                        registryURL:
-                          description: RegistryURL specifies the URL of a Helm chart
-                            registry. It may be a classic chart registry (using HTTP/S)
-                            OR an OCI registry. This field is required.
+                        repoURL:
+                          description: RepoURL specifies the URL of a Helm chart repository.
+                            It may be a classic chart repository (using HTTP/S) OR
+                            a repository within an OCI registry. Classic chart repositories
+                            can contain differently named charts. When this field
+                            points to such a repository, the Name field MUST also
+                            be used to specify the name of the desired chart within
+                            that repository. In the case of a repository within an
+                            OCI registry, the URL implicitly points to a specific
+                            chart and the Name field MUST NOT be used. The RepoURL
+                            field is required.
                           minLength: 1
                           pattern: ^(((https?)|(oci))://)([\w\d\.\-]+)(:[\d]+)?(/.*)*$
                           type: string
@@ -69,8 +77,7 @@ spec:
                             of breaking changes. More info: https://github.com/masterminds/semver#checking-version-constraints'
                           type: string
                       required:
-                      - name
-                      - registryURL
+                      - repoURL
                       type: object
                     git:
                       description: Git describes a subscriptions to a Git repository.
diff --git a/internal/api/query_freights_v1alpha1.go b/internal/api/query_freights_v1alpha1.go
index 74960a2b..f6fb2c81 100644
--- a/internal/api/query_freights_v1alpha1.go
+++ b/internal/api/query_freights_v1alpha1.go
@@ -2,7 +2,7 @@ package api
 
 import (
 	"context"
-	"fmt"
+	"path"
 	"sort"
 
 	"connectrpc.com/connect"
@@ -98,7 +98,7 @@ func (s *server) QueryFreight(
 	case GroupByGitRepository:
 		freightGroups = groupByGitRepo(freight, req.Msg.GetGroup())
 	case GroupByChartRepository:
-		freightGroups = groupByChartRepo(freight, req.Msg.GetGroup())
+		freightGroups = groupByChart(freight, req.Msg.GetGroup())
 	default:
 		freightGroups = noGroupBy(freight)
 	}
@@ -283,16 +283,17 @@ func groupByGitRepo(
 	return groups
 }
 
-func groupByChartRepo(
+func groupByChart(
 	freight []kargoapi.Freight,
 	group string,
 ) map[string]*svcv1alpha1.FreightList {
 	groups := make(map[string]*svcv1alpha1.FreightList)
 	for _, f := range freight {
 		for _, c := range f.Charts {
-			repoURL := fmt.Sprintf("%s/%s", c.RegistryURL, c.Name)
-			if group == "" || repoURL == group {
-				groups[repoURL] = appendToFreightList(groups[repoURL], f)
+			// path.Join accounts for the possibility that chart.Name is empty
+			key := path.Join(c.RepoURL, c.Name)
+			if group == "" || key == group {
+				groups[key] = appendToFreightList(groups[key], f)
 			}
 		}
 	}
@@ -376,7 +377,8 @@ func getRepoAndTag(s *apiv1alpha1.Freight) (string, string, *semver.Version) {
 		repo = s.Images[0].RepoUrl
 		tag = s.Images[0].Tag
 	} else if len(s.Charts) > 0 {
-		repo = s.Charts[0].RegistryUrl + "/" + s.Charts[0].Name
+		// path.Join accounts for the possibility that chart.Name is empty
+		repo = path.Join(s.Charts[0].RepoUrl, s.Charts[0].Name)
 		tag = s.Charts[0].Version
 	} else {
 		return "", "", nil
diff --git a/internal/api/types/v1alpha1/types.go b/internal/api/types/v1alpha1/types.go
index b8dfc7e9..934bb4b8 100644
--- a/internal/api/types/v1alpha1/types.go
+++ b/internal/api/types/v1alpha1/types.go
@@ -241,9 +241,9 @@ func FromChartProto(c *v1alpha1.Chart) *kargoapi.Chart {
 		return nil
 	}
 	return &kargoapi.Chart{
-		RegistryURL: c.GetRegistryUrl(),
-		Name:        c.GetName(),
-		Version:     c.GetVersion(),
+		RepoURL: c.GetRepoUrl(),
+		Name:    c.GetName(),
+		Version: c.GetVersion(),
 	}
 }
 
@@ -350,7 +350,7 @@ func FromChartSubscriptionProto(s *v1alpha1.ChartSubscription) *kargoapi.ChartSu
 		return nil
 	}
 	return &kargoapi.ChartSubscription{
-		RegistryURL:      s.GetRegistryUrl(),
+		RepoURL:          s.GetRepoUrl(),
 		Name:             s.GetName(),
 		SemverConstraint: s.GetSemverConstraint(),
 	}
@@ -476,9 +476,9 @@ func FromHelmChartDependencyUpdateProto(
 		return nil
 	}
 	return &kargoapi.HelmChartDependencyUpdate{
-		RegistryURL: u.GetRegistryUrl(),
-		Name:        u.GetName(),
-		ChartPath:   u.GetChartPath(),
+		Repository: u.GetRepository(),
+		Name:       u.GetName(),
+		ChartPath:  u.GetChartPath(),
 	}
 }
 
@@ -820,7 +820,7 @@ func ToImageSubscriptionProto(i kargoapi.ImageSubscription) *v1alpha1.ImageSubsc
 
 func ToChartSubscriptionProto(c kargoapi.ChartSubscription) *v1alpha1.ChartSubscription {
 	return &v1alpha1.ChartSubscription{
-		RegistryUrl:      c.RegistryURL,
+		RepoUrl:          c.RepoURL,
 		Name:             proto.String(c.Name),
 		SemverConstraint: proto.String(c.SemverConstraint),
 	}
@@ -923,9 +923,9 @@ func ToHelmImageUpdateProto(h kargoapi.HelmImageUpdate) *v1alpha1.HelmImageUpdat
 
 func ToHelmChartDependencyUpdateProto(h kargoapi.HelmChartDependencyUpdate) *v1alpha1.HelmChartDependencyUpdate {
 	return &v1alpha1.HelmChartDependencyUpdate{
-		RegistryUrl: h.RegistryURL,
-		Name:        h.Name,
-		ChartPath:   h.ChartPath,
+		Repository: h.Repository,
+		Name:       h.Name,
+		ChartPath:  h.ChartPath,
 	}
 }
 
@@ -1119,9 +1119,9 @@ func ToImageProto(i kargoapi.Image) *v1alpha1.Image {
 
 func ToChartProto(c kargoapi.Chart) *v1alpha1.Chart {
 	return &v1alpha1.Chart{
-		RegistryUrl: c.RegistryURL,
-		Name:        c.Name,
-		Version:     c.Version,
+		RepoUrl: c.RepoURL,
+		Name:    c.Name,
+		Version: c.Version,
 	}
 }
 
diff --git a/internal/controller/promotion/argocd.go b/internal/controller/promotion/argocd.go
index 1952b7fe..badf7461 100644
--- a/internal/controller/promotion/argocd.go
+++ b/internal/controller/promotion/argocd.go
@@ -3,6 +3,7 @@ package promotion
 import (
 	"context"
 	"fmt"
+	"path"
 	"strings"
 
 	"github.com/gobwas/glob"
@@ -295,11 +296,21 @@ func applyArgoCDSourceUpdate(
 	if source.Chart != "" || update.Chart != "" {
 		// Infer that we're dealing with a chart repo. No need to normalize the
 		// repo URL here.
-		if source.RepoURL != update.RepoURL || source.Chart != update.Chart {
+
+		// Kargo uses the "oci://" prefix, but Argo CD does not.
+		if source.RepoURL != strings.TrimPrefix(update.RepoURL, "oci://") || source.Chart != update.Chart {
 			return source, nil
 		}
+		// If we get to here, we have confirmed that this update is applicable to
+		// this source.
+		//
+		// Now find the chart in the new freight that corresponds to this
+		// source.
 		for _, chart := range newFreight.Charts {
-			if chart.RegistryURL == source.RepoURL && chart.Name == source.Chart {
+			// path.Join accounts for the possibility that chart.Name is empty
+			//
+			// Kargo uses the "oci://" prefix, but Argo CD does not.
+			if path.Join(strings.TrimPrefix(chart.RepoURL, "oci://"), chart.Name) == path.Join(source.RepoURL, source.Chart) {
 				source.TargetRevision = chart.Version
 				break
 			}
@@ -311,6 +322,10 @@ func applyArgoCDSourceUpdate(
 		if sourceRepoURL != git.NormalizeGitURL(update.RepoURL) {
 			return source, nil
 		}
+		// If we get to here, we have confirmed that this update is applicable to
+		// this source.
+		//
+		// Now find the commit in the new freight that corresponds to this source.
 		for _, commit := range newFreight.Commits {
 			if git.NormalizeGitURL(commit.RepoURL) == sourceRepoURL {
 				source.TargetRevision = commit.ID
diff --git a/internal/controller/promotion/helm.go b/internal/controller/promotion/helm.go
index 38265a16..b12bd902 100644
--- a/internal/controller/promotion/helm.go
+++ b/internal/controller/promotion/helm.go
@@ -3,6 +3,7 @@ package promotion
 import (
 	"fmt"
 	"os"
+	"path"
 	"path/filepath"
 
 	"github.com/pkg/errors"
@@ -191,7 +192,8 @@ func buildChartDependencyChanges(
 	// Build a table of charts --> versions
 	versionsByChart := make(map[string]string, len(charts))
 	for _, chart := range charts {
-		key := fmt.Sprintf("%s:%s", chart.RegistryURL, chart.Name)
+		// path.Join accounts for the possibility that chart.Name is empty
+		key := path.Join(chart.RepoURL, chart.Name)
 		versionsByChart[key] = chart.Version
 	}
 
@@ -222,7 +224,7 @@ func buildChartDependencyChanges(
 				errors.Wrapf(err, "error unmarshaling %q", absChartYAMLPath)
 		}
 		for i, dependency := range chartYAMLObj.Dependencies {
-			chartKey := fmt.Sprintf("%s:%s", dependency.Repository, dependency.Name)
+			chartKey := path.Join(dependency.Repository, dependency.Name)
 			version, found := versionsByChart[chartKey]
 			if !found {
 				continue
diff --git a/internal/controller/stages/health.go b/internal/controller/stages/health.go
index 44273979..1085c855 100644
--- a/internal/controller/stages/health.go
+++ b/internal/controller/stages/health.go
@@ -3,6 +3,7 @@ package stages
 import (
 	"context"
 	"fmt"
+	"path"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	argocd "github.com/akuity/kargo/internal/controller/argocd/api/v1alpha1"
@@ -119,21 +120,24 @@ func (r *reconciler) checkHealth(
 		}
 
 		var desiredRevision string
-		sourceGitRepoURL := git.NormalizeGitURL(app.Spec.Source.RepoURL)
-		for _, commit := range currentFreight.Commits {
-			if git.NormalizeGitURL(commit.RepoURL) == sourceGitRepoURL {
-				if commit.HealthCheckCommit != "" {
-					desiredRevision = commit.HealthCheckCommit
-				} else {
-					desiredRevision = commit.ID
+		if app.Spec.Source.Chart == "" {
+			// This source points to a git repository
+			sourceGitRepoURL := git.NormalizeGitURL(app.Spec.Source.RepoURL)
+			for _, commit := range currentFreight.Commits {
+				if git.NormalizeGitURL(commit.RepoURL) == sourceGitRepoURL {
+					if commit.HealthCheckCommit != "" {
+						desiredRevision = commit.HealthCheckCommit
+					} else {
+						desiredRevision = commit.ID
+					}
 				}
+				break
 			}
-			break
-		}
-		if desiredRevision == "" {
+		} else {
+			// This source points to a Helm chart
 			for _, chart := range currentFreight.Charts {
-				if chart.RegistryURL == app.Spec.Source.RepoURL &&
-					chart.Name == app.Spec.Source.Chart {
+				// path.Join accounts for the possibility that chart.Name is empty
+				if path.Join(chart.RepoURL, chart.Name) == path.Join(app.Spec.Source.RepoURL, app.Spec.Source.Chart) {
 					desiredRevision = chart.Version
 					break
 				}
diff --git a/internal/controller/warehouses/helm.go b/internal/controller/warehouses/helm.go
index 67d5e072..4e4cb0d6 100644
--- a/internal/controller/warehouses/helm.go
+++ b/internal/controller/warehouses/helm.go
@@ -4,7 +4,6 @@ import (
 	"context"
 
 	"github.com/pkg/errors"
-	log "github.com/sirupsen/logrus"
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/credentials"
@@ -12,7 +11,7 @@ import (
 	"github.com/akuity/kargo/internal/logging"
 )
 
-func (r *reconciler) getLatestCharts(
+func (r *reconciler) selectCharts(
 	ctx context.Context,
 	namespace string,
 	subs []kargoapi.RepoSubscription,
@@ -26,18 +25,18 @@ func (r *reconciler) getLatestCharts(
 
 		sub := s.Chart
 
-		logger := logging.LoggerFromContext(ctx).WithFields(log.Fields{
-			"registry": sub.RegistryURL,
-			"chart":    sub.Name,
-		})
+		logger := logging.LoggerFromContext(ctx).WithField("repoURL", sub.RepoURL)
+		if sub.Name != "" {
+			logger = logger.WithField("chart", sub.Name)
+		}
 
 		creds, ok, err :=
-			r.credentialsDB.Get(ctx, namespace, credentials.TypeHelm, sub.RegistryURL)
+			r.credentialsDB.Get(ctx, namespace, credentials.TypeHelm, sub.RepoURL)
 		if err != nil {
 			return nil, errors.Wrapf(
 				err,
-				"error obtaining credentials for chart registry %q",
-				sub.RegistryURL,
+				"error obtaining credentials for chart repository %q",
+				sub.RepoURL,
 			)
 		}
 
@@ -52,28 +51,41 @@ func (r *reconciler) getLatestCharts(
 			logger.Debug("found no credentials for chart repo")
 		}
 
-		vers, err := r.getLatestChartVersionFn(
+		vers, err := r.selectChartVersionFn(
 			ctx,
-			sub.RegistryURL,
+			sub.RepoURL,
 			sub.Name,
 			sub.SemverConstraint,
 			helmCreds,
 		)
 		if err != nil {
+			if sub.Name == "" {
+				return nil, errors.Wrapf(
+					err,
+					"error searching for latest version of chart in repository %q",
+					sub.RepoURL,
+				)
+			}
 			return nil, errors.Wrapf(
 				err,
-				"error searching for latest version of chart %q in registry %q",
+				"error searching for latest version of chart %q in repository %q",
 				sub.Name,
-				sub.RegistryURL,
+				sub.RepoURL,
 			)
 		}
 
 		if vers == "" {
 			logger.Error("found no suitable chart version")
+			if sub.Name == "" {
+				return nil, errors.Errorf(
+					"found no suitable version of chart in repository %q",
+					sub.RepoURL,
+				)
+			}
 			return nil, errors.Errorf(
-				"found no suitable version of chart %q in registry %q",
+				"found no suitable version of chart %q in repository %q",
 				sub.Name,
-				sub.RegistryURL,
+				sub.RepoURL,
 			)
 		}
 		logger.WithField("version", vers).
@@ -82,9 +94,9 @@ func (r *reconciler) getLatestCharts(
 		charts = append(
 			charts,
 			kargoapi.Chart{
-				RegistryURL: sub.RegistryURL,
-				Name:        sub.Name,
-				Version:     vers,
+				RepoURL: sub.RepoURL,
+				Name:    sub.Name,
+				Version: vers,
 			},
 		)
 	}
diff --git a/internal/controller/warehouses/warehouses.go b/internal/controller/warehouses/warehouses.go
index 2311dd82..b5ebe78d 100644
--- a/internal/controller/warehouses/warehouses.go
+++ b/internal/controller/warehouses/warehouses.go
@@ -69,15 +69,15 @@ type reconciler struct {
 		creds *image.Credentials,
 	) (string, string, error)
 
-	getLatestChartsFn func(
+	selectChartsFn func(
 		ctx context.Context,
 		namespace string,
 		subs []kargoapi.RepoSubscription,
 	) ([]kargoapi.Chart, error)
 
-	getLatestChartVersionFn func(
+	selectChartVersionFn func(
 		ctx context.Context,
-		registryURL string,
+		repoURL string,
 		chart string,
 		semverConstraint string,
 		creds *helm.Credentials,
@@ -155,8 +155,8 @@ func newReconciler(
 	r.checkoutTagFn = r.checkoutTag
 	r.getLatestImagesFn = r.getLatestImages
 	r.getImageRefsFn = getImageRefs
-	r.getLatestChartsFn = r.getLatestCharts
-	r.getLatestChartVersionFn = helm.GetLatestChartVersion
+	r.selectChartsFn = r.selectCharts
+	r.selectChartVersionFn = helm.SelectChartVersion
 	r.selectCommitMetaFn = r.selectCommitMeta
 	r.getAvailableFreightAliasFn = r.getAvailableFreightAlias
 	r.createFreightFn = kubeClient.Create
@@ -310,7 +310,7 @@ func (r *reconciler) getLatestFreightFromRepos(
 	}
 	logger.Debug("synced image repo subscriptions")
 
-	latestCharts, err := r.getLatestChartsFn(
+	selectedCharts, err := r.selectChartsFn(
 		ctx,
 		warehouse.Namespace,
 		warehouse.Spec.Subscriptions,
@@ -331,7 +331,7 @@ func (r *reconciler) getLatestFreightFromRepos(
 		},
 		Commits: selectedCommits,
 		Images:  latestImages,
-		Charts:  latestCharts,
+		Charts:  selectedCharts,
 	}
 	freight.UpdateID()
 	freight.ObjectMeta.Name = freight.ID
diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go
index b3b7a305..84a19310 100644
--- a/internal/credentials/credentials.go
+++ b/internal/credentials/credentials.go
@@ -262,11 +262,9 @@ func getCredentialsSecret(
 	repoURL string,
 	acceptPrefixMatch bool,
 ) (*corev1.Secret, error) {
-	if credType == TypeGit {
-		// This is important. We don't want the presence or absence of ".git" at the
-		// end of the URL to affect credential lookups.
-		repoURL = git.NormalizeGitURL(repoURL)
-	}
+	repoURL = normalizeChartRepositoryURL( // This should be safe even on non-chart repo URLs
+		git.NormalizeGitURL(repoURL), // This should be safe even on non-Git URLs
+	)
 
 	secrets := corev1.SecretList{}
 	if err := kubeClient.List(
@@ -291,17 +289,33 @@ func getCredentialsSecret(
 		if !ok {
 			continue
 		}
-		url := string(urlBytes)
+		url := normalizeChartRepositoryURL( // This should be safe even on non-chart repo URLs
+			git.NormalizeGitURL( // This should be safe even on non-Git URLs
+				string(urlBytes),
+			),
+		)
 		if acceptPrefixMatch && strings.HasPrefix(repoURL, url) {
 			return &secret, nil
 		}
-		if !acceptPrefixMatch && git.NormalizeGitURL(url) == repoURL {
+		if !acceptPrefixMatch && url == repoURL {
 			return &secret, nil
 		}
 	}
 	return nil, nil
 }
 
+// NormalizeURL normalizes a chart repository URL for purposes of comparison.
+// Crucially, this function removes the oci:// prefix from the URL if there is
+// one.
+func normalizeChartRepositoryURL(repo string) string {
+	return strings.TrimPrefix(
+		strings.ToLower(
+			strings.TrimSpace(repo),
+		),
+		"oci://",
+	)
+}
+
 func secretToCreds(secret *corev1.Secret) Credentials {
 	return Credentials{
 		Username:      string(secret.Data["username"]),
diff --git a/internal/helm/creds.go b/internal/helm/creds.go
index 5d0d8483..d4710f6a 100644
--- a/internal/helm/creds.go
+++ b/internal/helm/creds.go
@@ -4,9 +4,9 @@ package helm
 // repository.
 type Credentials struct {
 	// Username identifies a principal, which combined with the value of the
-	// Password field, can be used for reading from some remote registry.
+	// Password field, can be used for reading from some remote repository.
 	Username string
 	// Password, when combined with the principal identified by the Username
-	// field, can be used for both reading from some remote registry.
+	// field, can be used for both reading from some remote repository.
 	Password string
 }
diff --git a/internal/helm/helm.go b/internal/helm/helm.go
index e5e667b0..759615b1 100644
--- a/internal/helm/helm.go
+++ b/internal/helm/helm.go
@@ -19,62 +19,67 @@ import (
 	libExec "github.com/akuity/kargo/internal/exec"
 )
 
-// GetLatestChartVersion connects to the Helm chart registry specified by
-// registryURL and retrieves all available versions of the chart found therein.
-// The registry can be either a classic chart registry (using HTTP/S) or an OCI
-// registry. If no semverConstraint is provided (empty string is passed), then
-// the version that is semantically greatest will be returned. If a
-// semverConstraint is specified, then the semantically greatest version
-// satisfying that constraint will be returned. If no version satisfies the
-// constraint, the empty string is returned. Provided credentials may be nil for
-// public registries, but must be non-nil for private registries.
-func GetLatestChartVersion(
+// SelectChartVersion connects to the Helm chart repository specified by
+// repoURL and retrieves all available versions of the chart found therein. The
+// repository can be either a classic chart repository (using HTTP/S) or a
+// repository within an OCI registry. Classic chart repositories can contain
+// differently named charts. When repoURL points to such a repository, the name
+// argument must specify the name of the chart within the repository. In the
+// case of a repository within an OCI registry, the URL implicitly points to a
+// specific chart and the name argument must be empty. If no semverConstraint is
+// provided (empty string is passed), then the version that is semantically
+// greatest will be returned. If a semverConstraint is specified, then the
+// semantically greatest version satisfying that constraint will be returned. If
+// no version satisfies the constraint, the empty string is returned. Provided
+// credentials may be nil for public repositories, but must be non-nil for
+// private repositories.
+func SelectChartVersion(
 	ctx context.Context,
-	registryURL string,
+	repoURL string,
 	chart string,
 	semverConstraint string,
 	creds *Credentials,
 ) (string, error) {
 	var versions []string
 	var err error
-	if strings.HasPrefix(registryURL, "http://") ||
-		strings.HasPrefix(registryURL, "https://") {
+	if strings.HasPrefix(repoURL, "http://") ||
+		strings.HasPrefix(repoURL, "https://") {
 		versions, err =
-			getChartVersionsFromClassicRegistry(registryURL, chart, creds)
-	} else if strings.HasPrefix(registryURL, "oci://") {
+			getChartVersionsFromClassicRepo(repoURL, chart, creds)
+	} else if strings.HasPrefix(repoURL, "oci://") {
 		versions, err =
-			getChartVersionsFromOCIRegistry(ctx, registryURL, chart, creds)
+			getChartVersionsFromOCIRepo(ctx, repoURL, creds)
 	} else {
-		return "", errors.Errorf("registry URL %q is invalid", registryURL)
+		return "", errors.Errorf("repository URL %q is invalid", repoURL)
 	}
 	if err != nil {
 		return "", errors.Wrapf(
 			err,
-			"error retrieving versions of chart %q from registry %q",
+			"error retrieving versions of chart %q from repository %q",
 			chart,
-			registryURL,
+			repoURL,
 		)
 	}
 	latestVersion, err := getLatestVersion(versions, semverConstraint)
 	return latestVersion, errors.Wrapf(
 		err,
-		"error determining latest version of chart %q from  registry %q",
+		"error determining latest version of chart %q from repository %q",
 		chart,
-		registryURL,
+		repoURL,
 	)
 }
 
-// getChartVersionsFromClassicRegistry connects to the classic (HTTP/S) chart
-// registry specified by registryURL and retrieves all available versions of the
-// specified chart. The provided registryURL MUST begin with protocol http:// or
-// https://. Provided credentials may be nil for public registries, but must be
-// non-nil for private registries.
-func getChartVersionsFromClassicRegistry(
-	registryURL string,
+// getChartVersionsFromClassicRepo connects to the classic (HTTP/S) chart
+// repository specified by repoURL and retrieves all available versions of the
+// specified chart. The provided repoURL MUST begin with protocol http:// or
+// https://. Provided credentials may be nil for public repositories, but must
+// be non-nil for private repositories.
+func getChartVersionsFromClassicRepo(
+	repoURL string,
 	chart string,
 	creds *Credentials,
 ) ([]string, error) {
-	indexURL := fmt.Sprintf("%s/index.yaml", strings.TrimSuffix(registryURL, "/"))
+	indexURL := fmt.Sprintf("%s/index.yaml", strings.TrimSuffix(repoURL, "/"))
 	req, err := http.NewRequest(http.MethodGet, indexURL, nil)
 	if err != nil {
 		return nil,
@@ -86,12 +91,12 @@ func getChartVersionsFromClassicRegistry(
 	res, err := http.DefaultClient.Do(req)
 	if err != nil {
 		return nil,
-			errors.Wrapf(err, "error querying registry index at %q", indexURL)
+			errors.Wrapf(err, "error querying repository index at %q", indexURL)
 	}
 	if res.StatusCode != http.StatusOK {
 		return nil,
 			errors.Errorf(
-				"received unexpected HTTP %d when querying registry index at %q",
+				"received unexpected HTTP %d when querying repository index at %q",
 				res.StatusCode,
 				indexURL,
 			)
@@ -100,7 +105,7 @@ func getChartVersionsFromClassicRegistry(
 	resBodyBytes, err := io.ReadAll(res.Body)
 	if err != nil {
 		return nil,
-			errors.Wrapf(err, "error reading registry index from %q", indexURL)
+			errors.Wrapf(err, "error reading repository index from %q", indexURL)
 	}
 	index := struct {
 		Entries map[string][]struct {
@@ -109,12 +114,12 @@ func getChartVersionsFromClassicRegistry(
 	}{}
 	if err = yaml.Unmarshal(resBodyBytes, &index); err != nil {
 		return nil,
-			errors.Wrapf(err, "error unmarshaling registry index from %q", indexURL)
+			errors.Wrapf(err, "error unmarshaling repository index from %q", indexURL)
 	}
 	entries, ok := index.Entries[chart]
 	if !ok {
 		return nil, errors.Errorf(
-			"no versions of chart %q found in registry index from %q",
+			"no versions of chart %q found in repository index from %q",
 			chart,
 			indexURL,
 		)
@@ -126,21 +131,21 @@ func getChartVersionsFromClassicRegistry(
 	return versions, nil
 }
 
-// getChartVersionsFromOCIRegistry connects to the OCI registry specified by
-// registryURL and retrieves all available versions of the specified chart.
-// Provided credentials may be nil for public registries, but must be non-nil
-// for private registries.
-func getChartVersionsFromOCIRegistry(
+// getChartVersionsFromOCIRepo connects to the OCI repository specified by
+// repoURL and retrieves all available versions of the specified chart. Provided
+// credentials may be nil for public repositories, but must be non-nil for
+// private repositories.
+func getChartVersionsFromOCIRepo(
 	ctx context.Context,
-	registryURL string,
-	chart string,
+	repoURL string,
 	creds *Credentials,
 ) ([]string, error) {
+	ref, err := registry.ParseReference(strings.TrimPrefix(repoURL, "oci://"))
+	if err != nil {
+		return nil, errors.Wrapf(err, "error parsing repository URL %q", repoURL)
+	}
 	rep := &remote.Repository{
-		Reference: registry.Reference{
-			Registry:   strings.TrimPrefix(registryURL, "oci://"),
-			Repository: chart,
-		},
+		Reference: ref,
 		Client: &auth.Client{
 			Credential: func(context.Context, string) (auth.Credential, error) {
 				if creds != nil {
@@ -159,9 +164,8 @@ func getChartVersionsFromOCIRegistry(
 			versions = append(versions, t...)
 			return nil
 		}),
-		"error retrieving versions of chart %q from registry %q",
-		chart,
-		registryURL,
+		"error retrieving versions of chart from repository %q",
+		repoURL,
 	)
 }
 
diff --git a/internal/webhook/warehouse/webhook.go b/internal/webhook/warehouse/webhook.go
index 3dcdf850..4b315e94 100644
--- a/internal/webhook/warehouse/webhook.go
+++ b/internal/webhook/warehouse/webhook.go
@@ -3,6 +3,7 @@ package warehouse
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/Masterminds/semver"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -183,11 +184,35 @@ func (w *webhook) validateChartSub(
 	f *field.Path,
 	sub kargoapi.ChartSubscription,
 ) field.ErrorList {
+	errs := field.ErrorList{}
 	if err := validateSemverConstraint(
 		f.Child("semverConstraint"),
 		sub.SemverConstraint,
 	); err != nil {
-		return field.ErrorList{err}
+		errs = append(errs, err)
+	}
+	if strings.HasPrefix(sub.RepoURL, "oci://") && sub.Name != "" {
+		errs = append(
+			errs,
+			field.Invalid(
+				f.Child("name"),
+				sub.Name,
+				"must be empty if repoURL starts with oci://",
+			),
+		)
+	}
+	if (strings.HasPrefix(sub.RepoURL, "http://") || strings.HasPrefix(sub.RepoURL, "https://")) && sub.Name == "" {
+		errs = append(
+			errs,
+			field.Invalid(
+				f.Child("name"),
+				sub.Name,
+				"must be non-empty if repoURL starts with http:// or https://",
+			),
+		)
+	}
+	if len(errs) > 0 {
+		return errs
 	}
 	return nil
 }
diff --git a/pkg/api/v1alpha1/types.pb.go b/pkg/api/v1alpha1/types.pb.go
index dad296f0..9e8ed0d7 100644
--- a/pkg/api/v1alpha1/types.pb.go
+++ b/pkg/api/v1alpha1/types.pb.go
@@ -419,9 +419,9 @@ type Chart struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	RegistryUrl string `protobuf:"bytes,1,opt,name=registry_url,json=registryURL,proto3" json:"registry_url,omitempty"`
-	Name        string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
-	Version     string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"`
+	RepoUrl string `protobuf:"bytes,1,opt,name=repo_url,json=repoURL,proto3" json:"repo_url,omitempty"`
+	Name    string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+	Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"`
 }
 
 func (x *Chart) Reset() {
@@ -456,9 +456,9 @@ func (*Chart) Descriptor() ([]byte, []int) {
 	return file_v1alpha1_types_proto_rawDescGZIP(), []int{7}
 }
 
-func (x *Chart) GetRegistryUrl() string {
+func (x *Chart) GetRepoUrl() string {
 	if x != nil {
-		return x.RegistryUrl
+		return x.RepoUrl
 	}
 	return ""
 }
@@ -482,7 +482,7 @@ type ChartSubscription struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	RegistryUrl      string  `protobuf:"bytes,1,opt,name=registry_url,json=registryURL,proto3" json:"registry_url,omitempty"`
+	RepoUrl          string  `protobuf:"bytes,1,opt,name=repo_url,json=repoURL,proto3" json:"repo_url,omitempty"`
 	Name             *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"`
 	SemverConstraint *string `protobuf:"bytes,3,opt,name=semver_constraint,json=semverConstraint,proto3,oneof" json:"semver_constraint,omitempty"`
 }
@@ -519,9 +519,9 @@ func (*ChartSubscription) Descriptor() ([]byte, []int) {
 	return file_v1alpha1_types_proto_rawDescGZIP(), []int{8}
 }
 
-func (x *ChartSubscription) GetRegistryUrl() string {
+func (x *ChartSubscription) GetRepoUrl() string {
 	if x != nil {
-		return x.RegistryUrl
+		return x.RepoUrl
 	}
 	return ""
 }
@@ -1074,9 +1074,9 @@ type HelmChartDependencyUpdate struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	RegistryUrl string `protobuf:"bytes,1,opt,name=registry_url,json=registryURL,proto3" json:"registry_url,omitempty"`
-	Name        string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
-	ChartPath   string `protobuf:"bytes,3,opt,name=chart_path,json=chartPath,proto3" json:"chart_path,omitempty"`
+	Repository string `protobuf:"bytes,1,opt,name=repository,json=repositoryURL,proto3" json:"repository,omitempty"`
+	Name       string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
+	ChartPath  string `protobuf:"bytes,3,opt,name=chart_path,json=chartPath,proto3" json:"chart_path,omitempty"`
 }
 
 func (x *HelmChartDependencyUpdate) Reset() {
@@ -1111,9 +1111,9 @@ func (*HelmChartDependencyUpdate) Descriptor() ([]byte, []int) {
 	return file_v1alpha1_types_proto_rawDescGZIP(), []int{16}
 }
 
-func (x *HelmChartDependencyUpdate) GetRegistryUrl() string {
+func (x *HelmChartDependencyUpdate) GetRepository() string {
 	if x != nil {
-		return x.RegistryUrl
+		return x.Repository
 	}
 	return ""
 }
@@ -3515,16 +3515,15 @@ var file_v1alpha1_types_proto_rawDesc = []byte{
 	0x6e, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6b, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x69, 0x7a, 0x65, 0x42,
 	0x07, 0x0a, 0x05, 0x5f, 0x68, 0x65, 0x6c, 0x6d, 0x22, 0x1f, 0x0a, 0x1d, 0x4b, 0x61, 0x72, 0x67,
 	0x6f, 0x52, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e,
-	0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x22, 0x58, 0x0a, 0x05, 0x43, 0x68, 0x61,
-	0x72, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x5f, 0x75,
-	0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74,
-	0x72, 0x79, 0x55, 0x52, 0x4c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72,
-	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73,
-	0x69, 0x6f, 0x6e, 0x22, 0xa0, 0x01, 0x0a, 0x11, 0x43, 0x68, 0x61, 0x72, 0x74, 0x53, 0x75, 0x62,
-	0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x67,
-	0x69, 0x73, 0x74, 0x72, 0x79, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0b, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x55, 0x52, 0x4c, 0x12, 0x17, 0x0a, 0x04,
+	0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x22, 0x50, 0x0a, 0x05, 0x43, 0x68, 0x61,
+	0x72, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x65, 0x70, 0x6f, 0x55, 0x52, 0x4c, 0x12, 0x12, 0x0a,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x98, 0x01, 0x0a, 0x11,
+	0x43, 0x68, 0x61, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
+	0x6e, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x65, 0x70, 0x6f, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x65, 0x70, 0x6f, 0x55, 0x52, 0x4c, 0x12, 0x17, 0x0a, 0x04,
 	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61,
 	0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x30, 0x0a, 0x11, 0x73, 0x65, 0x6d, 0x76, 0x65, 0x72, 0x5f,
 	0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
@@ -3640,8 +3639,8 @@ var file_v1alpha1_types_proto_rawDesc = []byte{
 	0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e,
 	0x73, 0x22, 0x71, 0x0a, 0x19, 0x48, 0x65, 0x6c, 0x6d, 0x43, 0x68, 0x61, 0x72, 0x74, 0x44, 0x65,
 	0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x21,
-	0x0a, 0x0c, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x55, 0x52,
+	0x0a, 0x0a, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x6f, 0x72, 0x79, 0x55, 0x52,
 	0x4c, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
 	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x68, 0x61, 0x72, 0x74, 0x5f, 0x70,
 	0x61, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x68, 0x61, 0x72, 0x74,
diff --git a/ui/src/features/project/project-details/nodes/repo-node.tsx b/ui/src/features/project/project-details/nodes/repo-node.tsx
index 67e6e134..2abc8bac 100644
--- a/ui/src/features/project/project-details/nodes/repo-node.tsx
+++ b/ui/src/features/project/project-details/nodes/repo-node.tsx
@@ -25,7 +25,7 @@ export const RepoNode = ({ nodeData, children }: Props) => {
   const type = nodeData.type;
   const value =
     type === NodeType.REPO_CHART
-      ? nodeData.data.registryUrl
+      ? nodeData.data.repoUrl
       : type === NodeType.WAREHOUSE
         ? nodeData.data
         : nodeData.data.repoUrl;
@@ -43,7 +43,7 @@ export const RepoNode = ({ nodeData, children }: Props) => {
       <div className={styles.body}>
         {nodeData.type !== NodeType.WAREHOUSE && (
           <div className='mb-2'>
-            {nodeData.type === NodeType.REPO_CHART ? 'Registry URL' : 'Repo URL'}
+            Repo URL
             <Tooltip title={value}>
               <a
                 href={
diff --git a/ui/src/gen/schema/freights.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/freights.kargo.akuity.io_v1alpha1.json
index c5bca53f..e246040b 100644
--- a/ui/src/gen/schema/freights.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/freights.kargo.akuity.io_v1alpha1.json
@@ -15,8 +15,8 @@
             "description": "Name specifies the name of the chart.",
             "type": "string"
           },
-          "registryURL": {
-            "description": "RepoURL specifies the remote registry in which this chart is located.",
+          "repoURL": {
+            "description": "RepoURL specifies the URL of a Helm chart repository. Classic chart repositories (using HTTP/S) can contain differently named charts. When this field points to such a repository, the Name field will specify the name of the chart within the repository. In the case of a repository within an OCI registry, the URL implicitly points to a specific chart and the Name field will be empty.",
             "type": "string"
           },
           "version": {
diff --git a/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json
index aeff658b..13e3acf0 100644
--- a/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/stages.kargo.akuity.io_v1alpha1.json
@@ -41,7 +41,7 @@
                       "description": "ArgoCDSourceUpdate describes updates that should be applied to one of an Argo CD Application resource's sources.",
                       "properties": {
                         "chart": {
-                          "description": "Chart specifies a chart within a Helm chart registry if RepoURL points to a Helm chart registry. Application sources that point directly at a chart do so through a combination of their own RepoURL (registry) and Chart fields, so BOTH of those are used as criteria in selecting an Application source to update. This field MUST always be used when RepoURL points at a Helm chart registry. This field MUST never be used when RepoURL points at a Git repository.",
+                          "description": "Chart along with the RepoURL field identifies which of an Argo CD Application's sources this update is intended for. Note: As of Argo CD 2.6, Applications can use multiple sources. When the source to be updated references a Helm chart repository, the values of the RepoURL and Chart fields should exactly match the values of the fields of the same names in the source. i.e. Do not match the values of these two fields to your Warehouse; match them to the Application source you wish to update.",
                           "type": "string"
                         },
                         "helm": {
@@ -122,7 +122,7 @@
                           "type": "object"
                         },
                         "repoURL": {
-                          "description": "RepoURL identifies which of the Argo CD Application's sources this update is intended for. Note: As of Argo CD 2.6, Application's can use multiple sources.",
+                          "description": "RepoURL along with the Chart field identifies which of an Argo CD Application's sources this update is intended for. Note: As of Argo CD 2.6, Applications can use multiple sources. When the source to be updated references a Helm chart repository, the values of the RepoURL and Chart fields should exactly match the values of the fields of the same names in the source. i.e. Do not match the values of these two fields to your Warehouse; match them to the Application source you wish to update. This is a required field.",
                           "minLength": 1,
                           "type": "string"
                         },
@@ -166,12 +166,12 @@
                               "type": "string"
                             },
                             "name": {
-                              "description": "Name along with RegistryURL identify a subchart of the umbrella chart at ChartPath whose version should be updated.",
+                              "description": "Name along with Repository identifies a subchart of the umbrella chart at ChartPath whose version should be updated. The values of both fields should exactly match the values of the fields of the same names in a dependency expressed in the Chart.yaml of the umbrella chart at ChartPath. i.e. Do not match the values of these two fields to your Warehouse; match them to the Chart.yaml. This is a required field.",
                               "minLength": 1,
                               "type": "string"
                             },
-                            "registryURL": {
-                              "description": "RegistryURL along with Name identify a subchart of the umbrella chart at ChartPath whose version should be updated.",
+                            "repository": {
+                              "description": "Repository along with Name identifies a subchart of the umbrella chart at ChartPath whose version should be updated. The values of both fields should exactly match the values of the fields of the same names in a dependency expressed in the Chart.yaml of the umbrella chart at ChartPath. i.e. Do not match the values of these two fields to your Warehouse; match them to the Chart.yaml. This is a required field.",
                               "minLength": 1,
                               "pattern": "^(((https?)|(oci))://)([\\w\\d\\.\\-]+)(:[\\d]+)?(/.*)*$",
                               "type": "string"
@@ -180,7 +180,7 @@
                           "required": [
                             "chartPath",
                             "name",
-                            "registryURL"
+                            "repository"
                           ],
                           "type": "object"
                         },
@@ -456,8 +456,8 @@
                     "description": "Name specifies the name of the chart.",
                     "type": "string"
                   },
-                  "registryURL": {
-                    "description": "RepoURL specifies the remote registry in which this chart is located.",
+                  "repoURL": {
+                    "description": "RepoURL specifies the URL of a Helm chart repository. Classic chart repositories (using HTTP/S) can contain differently named charts. When this field points to such a repository, the Name field will specify the name of the chart within the repository. In the case of a repository within an OCI registry, the URL implicitly points to a specific chart and the Name field will be empty.",
                     "type": "string"
                   },
                   "version": {
@@ -592,8 +592,8 @@
                         "description": "Name specifies the name of the chart.",
                         "type": "string"
                       },
-                      "registryURL": {
-                        "description": "RepoURL specifies the remote registry in which this chart is located.",
+                      "repoURL": {
+                        "description": "RepoURL specifies the URL of a Helm chart repository. Classic chart repositories (using HTTP/S) can contain differently named charts. When this field points to such a repository, the Name field will specify the name of the chart within the repository. In the case of a repository within an OCI registry, the URL implicitly points to a specific chart and the Name field will be empty.",
                         "type": "string"
                       },
                       "version": {
@@ -817,8 +817,8 @@
                       "description": "Name specifies the name of the chart.",
                       "type": "string"
                     },
-                    "registryURL": {
-                      "description": "RepoURL specifies the remote registry in which this chart is located.",
+                    "repoURL": {
+                      "description": "RepoURL specifies the URL of a Helm chart repository. Classic chart repositories (using HTTP/S) can contain differently named charts. When this field points to such a repository, the Name field will specify the name of the chart within the repository. In the case of a repository within an OCI registry, the URL implicitly points to a specific chart and the Name field will be empty.",
                       "type": "string"
                     },
                     "version": {
diff --git a/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json b/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
index dca957ec..138a9655 100644
--- a/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
+++ b/ui/src/gen/schema/warehouses.kargo.akuity.io_v1alpha1.json
@@ -25,12 +25,11 @@
                 "description": "Chart describes a subscription to a Helm chart repository.",
                 "properties": {
                   "name": {
-                    "description": "Name specifies a Helm chart to subscribe to within the Helm chart registry specified by the RegistryURL field. This field is required.",
-                    "minLength": 1,
+                    "description": "Name specifies the name of a Helm chart to subscribe to within a classic chart repository specified by the RepoURL field. This field is required when the RepoURL field points to a classic chart repository and MUST otherwise be empty.",
                     "type": "string"
                   },
-                  "registryURL": {
-                    "description": "RegistryURL specifies the URL of a Helm chart registry. It may be a classic chart registry (using HTTP/S) OR an OCI registry. This field is required.",
+                  "repoURL": {
+                    "description": "RepoURL specifies the URL of a Helm chart repository. It may be a classic chart repository (using HTTP/S) OR a repository within an OCI registry. Classic chart repositories can contain differently named charts. When this field points to such a repository, the Name field MUST also be used to specify the name of the desired chart within that repository. In the case of a repository within an OCI registry, the URL implicitly points to a specific chart and the Name field MUST NOT be used. The RepoURL field is required.",
                     "minLength": 1,
                     "pattern": "^(((https?)|(oci))://)([\\w\\d\\.\\-]+)(:[\\d]+)?(/.*)*$",
                     "type": "string"
@@ -41,8 +40,7 @@
                   }
                 },
                 "required": [
-                  "name",
-                  "registryURL"
+                  "repoURL"
                 ],
                 "type": "object"
               },
diff --git a/ui/src/gen/v1alpha1/types_pb.ts b/ui/src/gen/v1alpha1/types_pb.ts
index 8948aa7c..78d2a9bc 100644
--- a/ui/src/gen/v1alpha1/types_pb.ts
+++ b/ui/src/gen/v1alpha1/types_pb.ts
@@ -319,9 +319,9 @@ export class KargoRenderPromotionMechanism extends Message<KargoRenderPromotionM
  */
 export class Chart extends Message<Chart> {
   /**
-   * @generated from field: string registry_url = 1 [json_name = "registryURL"];
+   * @generated from field: string repo_url = 1 [json_name = "repoURL"];
    */
-  registryUrl = "";
+  repoUrl = "";
 
   /**
    * @generated from field: string name = 2;
@@ -341,7 +341,7 @@ export class Chart extends Message<Chart> {
   static readonly runtime: typeof proto3 = proto3;
   static readonly typeName = "github.com.akuity.kargo.pkg.api.v1alpha1.Chart";
   static readonly fields: FieldList = proto3.util.newFieldList(() => [
-    { no: 1, name: "registry_url", jsonName: "registryURL", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+    { no: 1, name: "repo_url", jsonName: "repoURL", kind: "scalar", T: 9 /* ScalarType.STRING */ },
     { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
     { no: 3, name: "version", kind: "scalar", T: 9 /* ScalarType.STRING */ },
   ]);
@@ -368,9 +368,9 @@ export class Chart extends Message<Chart> {
  */
 export class ChartSubscription extends Message<ChartSubscription> {
   /**
-   * @generated from field: string registry_url = 1 [json_name = "registryURL"];
+   * @generated from field: string repo_url = 1 [json_name = "repoURL"];
    */
-  registryUrl = "";
+  repoUrl = "";
 
   /**
    * @generated from field: optional string name = 2;
@@ -390,7 +390,7 @@ export class ChartSubscription extends Message<ChartSubscription> {
   static readonly runtime: typeof proto3 = proto3;
   static readonly typeName = "github.com.akuity.kargo.pkg.api.v1alpha1.ChartSubscription";
   static readonly fields: FieldList = proto3.util.newFieldList(() => [
-    { no: 1, name: "registry_url", jsonName: "registryURL", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+    { no: 1, name: "repo_url", jsonName: "repoURL", kind: "scalar", T: 9 /* ScalarType.STRING */ },
     { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true },
     { no: 3, name: "semver_constraint", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true },
   ]);
@@ -826,9 +826,9 @@ export class ArgoCDAppSyncStatus extends Message<ArgoCDAppSyncStatus> {
  */
 export class HelmChartDependencyUpdate extends Message<HelmChartDependencyUpdate> {
   /**
-   * @generated from field: string registry_url = 1 [json_name = "registryURL"];
+   * @generated from field: string repository = 1 [json_name = "repositoryURL"];
    */
-  registryUrl = "";
+  repository = "";
 
   /**
    * @generated from field: string name = 2;
@@ -848,7 +848,7 @@ export class HelmChartDependencyUpdate extends Message<HelmChartDependencyUpdate
   static readonly runtime: typeof proto3 = proto3;
   static readonly typeName = "github.com.akuity.kargo.pkg.api.v1alpha1.HelmChartDependencyUpdate";
   static readonly fields: FieldList = proto3.util.newFieldList(() => [
-    { no: 1, name: "registry_url", jsonName: "registryURL", kind: "scalar", T: 9 /* ScalarType.STRING */ },
+    { no: 1, name: "repository", jsonName: "repositoryURL", kind: "scalar", T: 9 /* ScalarType.STRING */ },
     { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
     { no: 3, name: "chart_path", kind: "scalar", T: 9 /* ScalarType.STRING */ },
   ]);


Test patch:
diff --git a/api/v1alpha1/freight_types_test.go b/api/v1alpha1/freight_types_test.go
index 819d4276..fff671fe 100644
--- a/api/v1alpha1/freight_types_test.go
+++ b/api/v1alpha1/freight_types_test.go
@@ -91,9 +91,9 @@ func TestFreightUpdateID(t *testing.T) {
 		},
 		Charts: []Chart{
 			{
-				RegistryURL: "fake-chart-registry",
-				Name:        "fake-chart",
-				Version:     "fake-chart-version",
+				RepoURL: "fake-chart-repo",
+				Name:    "fake-chart",
+				Version: "fake-chart-version",
 			},
 		},
 	}
diff --git a/internal/api/query_freights_v1alpha1_test.go b/internal/api/query_freights_v1alpha1_test.go
index b3350eb6..6730f76d 100644
--- a/internal/api/query_freights_v1alpha1_test.go
+++ b/internal/api/query_freights_v1alpha1_test.go
@@ -303,26 +303,26 @@ func TestQueryFreight(t *testing.T) {
 					freight.Items = []kargoapi.Freight{
 						{
 							Charts: []kargoapi.Chart{{
-								RegistryURL: "fake-registry-url",
-								Name:        "fake-chart-name",
+								RepoURL: "fake-repo-url",
+								Name:    "fake-chart-name",
 							}},
 						},
 						{
 							Charts: []kargoapi.Chart{{
-								RegistryURL: "fake-registry-url",
-								Name:        "fake-chart-name",
+								RepoURL: "fake-repo-url",
+								Name:    "fake-chart-name",
 							}},
 						},
 						{
 							Charts: []kargoapi.Chart{{
-								RegistryURL: "fake-registry-url",
-								Name:        "another-fake-chart-name",
+								RepoURL: "fake-repo-url",
+								Name:    "another-fake-chart-name",
 							}},
 						},
 						{
 							Charts: []kargoapi.Chart{{
-								RegistryURL: "fake-registry-url",
-								Name:        "another-fake-chart-name",
+								RepoURL: "fake-repo-url",
+								Name:    "another-fake-chart-name",
 							}},
 						},
 					}
@@ -337,12 +337,12 @@ func TestQueryFreight(t *testing.T) {
 				require.Len(t, res.Msg.GetGroups(), 2)
 				require.Len(
 					t,
-					res.Msg.GetGroups()["fake-registry-url/fake-chart-name"].Freight,
+					res.Msg.GetGroups()["fake-repo-url/fake-chart-name"].Freight,
 					2,
 				)
 				require.Len(
 					t,
-					res.Msg.GetGroups()["fake-registry-url/another-fake-chart-name"].Freight,
+					res.Msg.GetGroups()["fake-repo-url/another-fake-chart-name"].Freight,
 					2,
 				)
 			},
@@ -759,30 +759,30 @@ func TestGroupByGitRepo(t *testing.T) {
 	}
 }
 
-func TestGroupByChartRepo(t *testing.T) {
+func TestGroupByChart(t *testing.T) {
 	testFreight := []kargoapi.Freight{
 		{
 			Charts: []kargoapi.Chart{{
-				RegistryURL: "fake-registry-url",
-				Name:        "fake-chart",
+				RepoURL: "fake-repo-url",
+				Name:    "fake-chart",
 			}},
 		},
 		{
 			Charts: []kargoapi.Chart{{
-				RegistryURL: "fake-registry-url",
-				Name:        "fake-chart",
+				RepoURL: "fake-repo-url",
+				Name:    "fake-chart",
 			}},
 		},
 		{
 			Charts: []kargoapi.Chart{{
-				RegistryURL: "another-fake-registry-url",
-				Name:        "fake-chart",
+				RepoURL: "another-fake-repo-url",
+				Name:    "fake-chart",
 			}},
 		},
 		{
 			Charts: []kargoapi.Chart{{
-				RegistryURL: "another-fake-registry-url",
-				Name:        "fake-chart",
+				RepoURL: "another-fake-repo-url",
+				Name:    "fake-chart",
 			}},
 		},
 	}
@@ -796,22 +796,22 @@ func TestGroupByChartRepo(t *testing.T) {
 			group: "",
 			assertions: func(groups map[string]*svcv1alpha1.FreightList) {
 				require.Len(t, groups, 2)
-				require.Len(t, groups["fake-registry-url/fake-chart"].Freight, 2)
-				require.Len(t, groups["another-fake-registry-url/fake-chart"].Freight, 2)
+				require.Len(t, groups["fake-repo-url/fake-chart"].Freight, 2)
+				require.Len(t, groups["another-fake-repo-url/fake-chart"].Freight, 2)
 			},
 		},
 		{
 			name:  "with group filter",
-			group: "fake-registry-url/fake-chart",
+			group: "fake-repo-url/fake-chart",
 			assertions: func(groups map[string]*svcv1alpha1.FreightList) {
 				require.Len(t, groups, 1)
-				require.Len(t, groups["fake-registry-url/fake-chart"].Freight, 2)
+				require.Len(t, groups["fake-repo-url/fake-chart"].Freight, 2)
 			},
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			testCase.assertions(groupByChartRepo(testFreight, testCase.group))
+			testCase.assertions(groupByChart(testFreight, testCase.group))
 		})
 	}
 }
diff --git a/internal/controller/promotion/argocd_test.go b/internal/controller/promotion/argocd_test.go
index af6aa6f4..cf39e86e 100644
--- a/internal/controller/promotion/argocd_test.go
+++ b/internal/controller/promotion/argocd_test.go
@@ -614,9 +614,9 @@ func TestApplyArgoCDSourceUpdate(t *testing.T) {
 			newFreight: kargoapi.FreightReference{
 				Charts: []kargoapi.Chart{
 					{
-						RegistryURL: "fake-url",
-						Name:        "fake-chart",
-						Version:     "fake-version",
+						RepoURL: "fake-url",
+						Name:    "fake-chart",
+						Version: "fake-version",
 					},
 				},
 			},
diff --git a/internal/controller/promotion/helm_test.go b/internal/controller/promotion/helm_test.go
index 6efd54d1..a128fa9f 100644
--- a/internal/controller/promotion/helm_test.go
+++ b/internal/controller/promotion/helm_test.go
@@ -360,7 +360,7 @@ func TestBuildChartDependencyChanges(t *testing.T) {
 	err = os.WriteFile(
 		filepath.Join(testFooChartDir, "Chart.yaml"),
 		[]byte(`dependencies:
-- repository: fake-registry
+- repository: fake-repo
   name: fake-chart
   version: placeholder
 `),
@@ -376,10 +376,10 @@ func TestBuildChartDependencyChanges(t *testing.T) {
 		filepath.Join(testBarChartDir, "Chart.yaml"),
 		// This fake chart has TWO dependencies -- one of which shouldn't be updated
 		[]byte(`dependencies:
-- repository: another-fake-registry
+- repository: another-fake-repo
   name: another-fake-chart
   version: placeholder
-- repository: yet-another-fake-registry
+- repository: yet-another-fake-repo
   name: yet-another-fake-chart
   version: placeholder
 `),
@@ -390,28 +390,28 @@ func TestBuildChartDependencyChanges(t *testing.T) {
 	// New charts
 	charts := []kargoapi.Chart{
 		{
-			RegistryURL: "fake-registry",
-			Name:        "fake-chart",
-			Version:     "fake-version",
+			RepoURL: "fake-repo",
+			Name:    "fake-chart",
+			Version: "fake-version",
 		},
 		{
-			RegistryURL: "another-fake-registry",
-			Name:        "another-fake-chart",
-			Version:     "another-fake-version",
+			RepoURL: "another-fake-repo",
+			Name:    "another-fake-chart",
+			Version: "another-fake-version",
 		},
 	}
 
 	// Instructions for how to update Chart.yaml files
 	chartUpdates := []kargoapi.HelmChartDependencyUpdate{
 		{
-			RegistryURL: "fake-registry",
-			Name:        "fake-chart",
-			ChartPath:   "charts/foo",
+			Repository: "fake-repo",
+			Name:       "fake-chart",
+			ChartPath:  "charts/foo",
 		},
 		{
-			RegistryURL: "another-fake-registry",
-			Name:        "another-fake-chart",
-			ChartPath:   "charts/bar",
+			Repository: "another-fake-repo",
+			Name:       "another-fake-chart",
+			ChartPath:  "charts/bar",
 		},
 		// Note there is no mention of how to update bar's second dependency, so
 		// we expect it to be left alone.
diff --git a/internal/controller/warehouses/helm_test.go b/internal/controller/warehouses/helm_test.go
index a4c42878..d59c1500 100644
--- a/internal/controller/warehouses/helm_test.go
+++ b/internal/controller/warehouses/helm_test.go
@@ -12,11 +12,11 @@ import (
 	"github.com/akuity/kargo/internal/helm"
 )
 
-func TestGetLatestCharts(t *testing.T) {
+func TestSelectCharts(t *testing.T) {
 	testCases := []struct {
-		name                    string
-		credentialsDB           credentials.Database
-		getLatestChartVersionFn func(
+		name                 string
+		credentialsDB        credentials.Database
+		selectChartVersionFn func(
 			context.Context,
 			string,
 			string,
@@ -26,7 +26,7 @@ func TestGetLatestCharts(t *testing.T) {
 		assertions func([]kargoapi.Chart, error)
 	}{
 		{
-			name: "error getting registry credentials",
+			name: "error getting repository credentials",
 			credentialsDB: &credentials.FakeDB{
 				GetFn: func(
 					context.Context,
@@ -43,7 +43,7 @@ func TestGetLatestCharts(t *testing.T) {
 				require.Contains(
 					t,
 					err.Error(),
-					"error obtaining credentials for chart registry",
+					"error obtaining credentials for chart",
 				)
 				require.Contains(t, err.Error(), "something went wrong")
 			},
@@ -61,7 +61,7 @@ func TestGetLatestCharts(t *testing.T) {
 					return credentials.Credentials{}, false, nil
 				},
 			},
-			getLatestChartVersionFn: func(
+			selectChartVersionFn: func(
 				context.Context,
 				string,
 				string,
@@ -93,7 +93,7 @@ func TestGetLatestCharts(t *testing.T) {
 					return credentials.Credentials{}, false, nil
 				},
 			},
-			getLatestChartVersionFn: func(
+			selectChartVersionFn: func(
 				context.Context,
 				string,
 				string,
@@ -120,7 +120,7 @@ func TestGetLatestCharts(t *testing.T) {
 					return credentials.Credentials{}, false, nil
 				},
 			},
-			getLatestChartVersionFn: func(
+			selectChartVersionFn: func(
 				context.Context,
 				string,
 				string,
@@ -135,9 +135,9 @@ func TestGetLatestCharts(t *testing.T) {
 				require.Equal(
 					t,
 					kargoapi.Chart{
-						RegistryURL: "fake-url",
-						Name:        "fake-chart",
-						Version:     "1.0.0",
+						RepoURL: "fake-url",
+						Name:    "fake-chart",
+						Version: "1.0.0",
 					},
 					charts[0],
 				)
@@ -147,17 +147,17 @@ func TestGetLatestCharts(t *testing.T) {
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
 			r := reconciler{
-				credentialsDB:           testCase.credentialsDB,
-				getLatestChartVersionFn: testCase.getLatestChartVersionFn,
+				credentialsDB:        testCase.credentialsDB,
+				selectChartVersionFn: testCase.selectChartVersionFn,
 			}
-			testCase.assertions(r.getLatestCharts(
+			testCase.assertions(r.selectCharts(
 				context.Background(),
 				"fake-namespace",
 				[]kargoapi.RepoSubscription{
 					{
 						Chart: &kargoapi.ChartSubscription{
-							RegistryURL: "fake-url",
-							Name:        "fake-chart",
+							RepoURL: "fake-url",
+							Name:    "fake-chart",
 						},
 					},
 				},
diff --git a/internal/controller/warehouses/warehouses_test.go b/internal/controller/warehouses/warehouses_test.go
index 0ebe6969..55994104 100644
--- a/internal/controller/warehouses/warehouses_test.go
+++ b/internal/controller/warehouses/warehouses_test.go
@@ -35,8 +35,8 @@ func TestNewReconciler(t *testing.T) {
 	require.NotNil(t, e.checkoutTagFn)
 	require.NotNil(t, e.getLatestImagesFn)
 	require.NotNil(t, e.getImageRefsFn)
-	require.NotNil(t, e.getLatestChartsFn)
-	require.NotNil(t, e.getLatestChartVersionFn)
+	require.NotNil(t, e.selectChartsFn)
+	require.NotNil(t, e.selectChartVersionFn)
 	require.NotNil(t, e.selectCommitMetaFn)
 	require.NotNil(t, e.getAvailableFreightAliasFn)
 	require.NotNil(t, e.createFreightFn)
@@ -281,7 +281,7 @@ func TestGetLatestFreightFromRepos(t *testing.T) {
 				) ([]kargoapi.Image, error) {
 					return nil, nil
 				},
-				getLatestChartsFn: func(
+				selectChartsFn: func(
 					context.Context,
 					string,
 					[]kargoapi.RepoSubscription,
@@ -327,16 +327,16 @@ func TestGetLatestFreightFromRepos(t *testing.T) {
 						},
 					}, nil
 				},
-				getLatestChartsFn: func(
+				selectChartsFn: func(
 					context.Context,
 					string,
 					[]kargoapi.RepoSubscription,
 				) ([]kargoapi.Chart, error) {
 					return []kargoapi.Chart{
 						{
-							RegistryURL: "fake-registry",
-							Name:        "fake-chart",
-							Version:     "fake-version",
+							RepoURL: "fake-repo",
+							Name:    "fake-chart",
+							Version: "fake-version",
 						},
 					}, nil
 				},
@@ -371,9 +371,9 @@ func TestGetLatestFreightFromRepos(t *testing.T) {
 						},
 						Charts: []kargoapi.Chart{
 							{
-								RegistryURL: "fake-registry",
-								Name:        "fake-chart",
-								Version:     "fake-version",
+								RepoURL: "fake-repo",
+								Name:    "fake-chart",
+								Version: "fake-version",
 							},
 						},
 					},
diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go
index ad27066f..7eb55362 100644
--- a/internal/credentials/credentials_test.go
+++ b/internal/credentials/credentials_test.go
@@ -6,7 +6,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -36,7 +36,7 @@ func TestGet(t *testing.T) {
 	const testURLPrefix = "myrepo.com"
 	const testURL = testURLPrefix + "/myrepo/myimage"
 	secretInNamespaceExact := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-namespace-exact",
 			Namespace: testNamespace,
 			Labels: map[string]string{
@@ -51,7 +51,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInNamespacePrefix := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-namespace-prefix",
 			Namespace: testNamespace,
 			Labels: map[string]string{
@@ -66,7 +66,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInGlobalExact := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-global-exact",
 			Namespace: testGlobalNamespaces[0],
 			Labels: map[string]string{
@@ -81,7 +81,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInGlobalPrefix := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-global-prefix",
 			Namespace: testGlobalNamespaces[0],
 			Labels: map[string]string{
@@ -96,7 +96,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInArgoCDNamespaceExact := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-argocd-exact",
 			Namespace: testArgoCDNameSpace,
 			Labels: map[string]string{
@@ -114,7 +114,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInArgoCDNamespacePrefix := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-argocd-prefix",
 			Namespace: testArgoCDNameSpace,
 			Labels: map[string]string{
@@ -132,7 +132,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInArgoCDNamespacePrefixMissingAuthorization := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-argocd-prefix",
 			Namespace: testArgoCDNameSpace,
 			Labels: map[string]string{
@@ -147,7 +147,7 @@ func TestGet(t *testing.T) {
 		},
 	}
 	secretInArgoCDNamespacePrefixWrongAuthorization := &corev1.Secret{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "in-argocd-prefix",
 			Namespace: testArgoCDNameSpace,
 			Labels: map[string]string{
@@ -274,113 +274,232 @@ func TestGet(t *testing.T) {
 }
 
 func TestGetCredentialsSecret(t *testing.T) {
+	const testSecretName = "fake-secret"
 	const testNamespace = "fake-namespace"
-	const testURLPrefix = "https://github.com/example"
-	const testURL = testURLPrefix + "/example.git"
-	const bogusTestURL = "https://github.com/bogus/bogus.git"
-	testClient := fake.NewClientBuilder().WithObjects(
-		&corev1.Secret{ // Should never match because it has no data
-			ObjectMeta: v1.ObjectMeta{
-				Name:      "creds-0",
-				Namespace: testNamespace,
-			},
+	testSecretMetadata := metav1.ObjectMeta{
+		Name:      testSecretName,
+		Namespace: testNamespace,
+	}
+	testCases := []struct {
+		name          string
+		secrets       []client.Object
+		repoType      Type
+		repoURL       string
+		prefixMatch   bool
+		shouldBeFound bool
+	}{
+		{
+			name:          "no secrets found",
+			secrets:       []client.Object{},
+			repoType:      TypeGit,
+			repoURL:       "https://github.com/example",
+			shouldBeFound: false,
 		},
-		&corev1.Secret{ // Should never match because its the wrong type of repo
-			ObjectMeta: v1.ObjectMeta{
-				Name:      "creds-1",
-				Namespace: testNamespace,
+		{
+			name: "no secrets of correct type",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeImage),
+						// This is not a realistic URL for an image, but we're trying to
+						// prove that type matters.
+						"url": []byte("https://github.com/example/example"),
+					},
+				},
 			},
-			Data: map[string][]byte{
-				"type": []byte(TypeImage),
-				"url":  []byte(testURL),
+			repoType:      TypeGit,
+			repoURL:       "https://github.com/example/example",
+			shouldBeFound: false,
+		},
+		{
+			name: "exact git repo URL match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeGit),
+						"url":  []byte("https://github.com/example/example"),
+					},
+				},
 			},
+			repoType:      TypeGit,
+			repoURL:       "https://github.com/example/example",
+			shouldBeFound: true,
 		},
-		&corev1.Secret{ // Should never match because its missing the url field
-			ObjectMeta: v1.ObjectMeta{
-				Name:      "creds-2",
-				Namespace: testNamespace,
+		{
+			name: "normalized git repo URL match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeGit),
+						"url":  []byte("https://github.com/example/example"),
+					},
+				},
 			},
-			Data: map[string][]byte{
-				"type": []byte(TypeGit),
+			repoType:      TypeGit,
+			repoURL:       "https://github.com/example/example.git",
+			shouldBeFound: true,
+		},
+		{
+			name: "git repo URL prefix match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeGit),
+						"url":  []byte("https://github.com/example"),
+					},
+				},
 			},
+			repoType:      TypeGit,
+			repoURL:       "https://github.com/example/example",
+			prefixMatch:   true,
+			shouldBeFound: true,
 		},
-		&corev1.Secret{ // Should be an exact match
-			ObjectMeta: v1.ObjectMeta{
-				Name:      "creds-3",
-				Namespace: testNamespace,
+		{
+			name: "exact image repo URL match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeImage),
+						"url":  []byte("ghcr.io/example/example"),
+					},
+				},
 			},
-			Data: map[string][]byte{
-				"type": []byte(TypeGit),
-				"url":  []byte(testURL),
+			repoType:      TypeImage,
+			repoURL:       "ghcr.io/example/example",
+			shouldBeFound: true,
+		},
+		{
+			name: "image repo URL prefix match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeImage),
+						"url":  []byte("ghcr.io/example"),
+					},
+				},
 			},
+			repoType:      TypeImage,
+			repoURL:       "ghcr.io/example/example",
+			prefixMatch:   true,
+			shouldBeFound: true,
 		},
-		&corev1.Secret{ // Should be a prefix match
-			ObjectMeta: v1.ObjectMeta{
-				Name:      "creds-4",
-				Namespace: testNamespace,
+		{
+			name: "exact chart repo https URL match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeHelm),
+						"url":  []byte("https://chart-museum.example.com/example"),
+					},
+				},
 			},
-			Data: map[string][]byte{
-				"type": []byte(TypeGit),
-				"url":  []byte(testURLPrefix),
+			repoType:      TypeHelm,
+			repoURL:       "https://chart-museum.example.com/example",
+			shouldBeFound: true,
+		},
+		{
+			name: "chart repo https URL prefix match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeHelm),
+						"url":  []byte("https://chart-museum.example.com"),
+					},
+				},
 			},
+			repoType:      TypeHelm,
+			repoURL:       "https://chart-museum.example.com/example",
+			prefixMatch:   true,
+			shouldBeFound: true,
 		},
-	).Build()
-	testCases := []struct {
-		name              string
-		repoURL           string
-		acceptPrefixMatch bool
-		assertions        func(*corev1.Secret, error)
-	}{
 		{
-			name:              "exact match not found",
-			repoURL:           bogusTestURL,
-			acceptPrefixMatch: false,
-			assertions: func(secret *corev1.Secret, err error) {
-				require.NoError(t, err)
-				require.Nil(t, secret)
+			name: "exact chart repo oci URL match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeHelm),
+						"url":  []byte("oci://ghcr.io/example/example"),
+					},
+				},
 			},
+			repoType:      TypeHelm,
+			repoURL:       "oci://ghcr.io/example/example",
+			shouldBeFound: true,
 		},
 		{
-			name:              "exact match found",
-			repoURL:           testURL,
-			acceptPrefixMatch: false,
-			assertions: func(secret *corev1.Secret, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, secret)
+			name: "chart repo oci URL prefix match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeHelm),
+						"url":  []byte("oci://ghcr.io/example"),
+					},
+				},
 			},
+			repoType:      TypeHelm,
+			repoURL:       "oci://ghcr.io/example/example",
+			prefixMatch:   true,
+			shouldBeFound: true,
 		},
 		{
-			name:              "prefix match not found",
-			repoURL:           bogusTestURL,
-			acceptPrefixMatch: true,
-			assertions: func(secret *corev1.Secret, err error) {
-				require.NoError(t, err)
-				require.Nil(t, secret)
+			name: "normalized chart repo oci URL match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeHelm),
+						"url":  []byte("ghcr.io/example/example"),
+					},
+				},
 			},
+			repoType:      TypeHelm,
+			repoURL:       "oci://ghcr.io/example/example",
+			shouldBeFound: true,
 		},
 		{
-			name:              "prefix match found",
-			repoURL:           testURL,
-			acceptPrefixMatch: true,
-			assertions: func(secret *corev1.Secret, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, secret)
+			name: "normalized chart repo oci URL prefix match",
+			secrets: []client.Object{
+				&corev1.Secret{
+					ObjectMeta: testSecretMetadata,
+					Data: map[string][]byte{
+						"type": []byte(TypeHelm),
+						"url":  []byte("ghcr.io/example"),
+					},
+				},
 			},
+			repoType:      TypeHelm,
+			repoURL:       "oci://ghcr.io/example/example",
+			prefixMatch:   true,
+			shouldBeFound: true,
 		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			testCase.assertions(
-				getCredentialsSecret(
-					context.Background(),
-					testClient,
-					testNamespace,
-					labels.Everything(),
-					TypeGit,
-					testCase.repoURL,
-					testCase.acceptPrefixMatch,
-				),
+			secret, err := getCredentialsSecret(
+				context.Background(),
+				fake.NewClientBuilder().WithObjects(testCase.secrets...).Build(),
+				testNamespace,
+				labels.Everything(),
+				testCase.repoType,
+				testCase.repoURL,
+				testCase.prefixMatch,
 			)
+			require.NoError(t, err)
+			if testCase.shouldBeFound {
+				require.NotNil(t, secret)
+			} else {
+				require.Nil(t, secret)
+			}
 		})
 	}
 }
diff --git a/internal/helm/helm_test.go b/internal/helm/helm_test.go
index 7a93421d..bfb784df 100644
--- a/internal/helm/helm_test.go
+++ b/internal/helm/helm_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestGetChartVersionsFromClassicRegistry(t *testing.T) {
+func TestGetChartVersionsFromClassicRepo(t *testing.T) {
 	// This is a mock registry. Depending on the request path, it returns a 404,
 	// invalid YAML, or valid YAML.
 	testServer := httptest.NewServer(
@@ -18,11 +18,11 @@ func TestGetChartVersionsFromClassicRegistry(t *testing.T) {
 			func(w http.ResponseWriter, r *http.Request) {
 				defer r.Body.Close()
 				switch r.URL.Path {
-				case "/bad-registry/index.yaml":
+				case "/bad-repo/index.yaml":
 					w.WriteHeader(http.StatusOK)
 					_, err := w.Write([]byte("this isn't yaml"))
 					require.NoError(t, err)
-				case "/fake-registry/index.yaml":
+				case "/fake-repo/index.yaml":
 					w.WriteHeader(http.StatusOK)
 					_, err := w.Write([]byte(`entries:
   fake-chart:
@@ -39,43 +39,43 @@ func TestGetChartVersionsFromClassicRegistry(t *testing.T) {
 	)
 	defer testServer.Close()
 	testCases := []struct {
-		name        string
-		registryURL string
-		chart       string
-		assertions  func(versions []string, err error)
+		name       string
+		repoURL    string
+		chart      string
+		assertions func(versions []string, err error)
 	}{
 		{
-			name:        "request for registry index returns non-200 status",
-			registryURL: fmt.Sprintf("%s/non-existent-registry", testServer.URL),
-			chart:       "fake-chart",
+			name:    "request for repo index returns non-200 status",
+			repoURL: fmt.Sprintf("%s/non-existent-repo", testServer.URL),
+			chart:   "fake-chart",
 			assertions: func(versions []string, err error) {
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "received unexpected HTTP 404")
 			},
 		},
 		{
-			name:        "index isn't valid YAML",
-			registryURL: fmt.Sprintf("%s/bad-registry", testServer.URL),
-			chart:       "fake-chart",
+			name:    "index isn't valid YAML",
+			repoURL: fmt.Sprintf("%s/bad-repo", testServer.URL),
+			chart:   "fake-chart",
 			assertions: func(versions []string, err error) {
 				require.Error(t, err)
-				require.Contains(t, err.Error(), "error unmarshaling registry index")
+				require.Contains(t, err.Error(), "error unmarshaling repository index")
 			},
 		},
 		{
-			name:        "no versions found",
-			registryURL: fmt.Sprintf("%s/fake-registry", testServer.URL),
-			chart:       "non-existent-chart",
+			name:    "no versions found",
+			repoURL: fmt.Sprintf("%s/fake-repo", testServer.URL),
+			chart:   "non-existent-chart",
 			assertions: func(versions []string, err error) {
 				require.Error(t, err)
 				require.Contains(t, err.Error(), "no versions of chart")
-				require.Contains(t, err.Error(), "found in registry index")
+				require.Contains(t, err.Error(), "found in repository index")
 			},
 		},
 		{
-			name:        "success",
-			registryURL: fmt.Sprintf("%s/fake-registry", testServer.URL),
-			chart:       "fake-chart",
+			name:    "success",
+			repoURL: fmt.Sprintf("%s/fake-repo", testServer.URL),
+			chart:   "fake-chart",
 			assertions: func(versions []string, err error) {
 				require.NoError(t, err)
 				require.Equal(t, []string{"1.0.0", "1.1.0", "1.2.0"}, versions)
@@ -85,8 +85,8 @@ func TestGetChartVersionsFromClassicRegistry(t *testing.T) {
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
 			testCase.assertions(
-				getChartVersionsFromClassicRegistry(
-					testCase.registryURL,
+				getChartVersionsFromClassicRepo(
+					testCase.repoURL,
 					testCase.chart,
 					nil,
 				),
@@ -95,15 +95,12 @@ func TestGetChartVersionsFromClassicRegistry(t *testing.T) {
 	}
 }
 
-func TestGetChartVersionsFromOCIRegistry(t *testing.T) {
+func TestGetChartVersionsFromOCIRepo(t *testing.T) {
 	// Instead of mocking out an OCI registry, it's more expedient to use Kargo's
 	// own chart repo on ghcr.io to test this.
-	const testRegistryURL = "oci://ghcr.io"
-	const testChart = "akuity/kargo-charts/kargo"
-	versions, err := getChartVersionsFromOCIRegistry(
+	versions, err := getChartVersionsFromOCIRepo(
 		context.Background(),
-		testRegistryURL,
-		testChart,
+		"oci://ghcr.io/akuity/kargo-charts/kargo",
 		nil,
 	)
 	require.NoError(t, err)
diff --git a/internal/webhook/warehouse/webhook_test.go b/internal/webhook/warehouse/webhook_test.go
index 03988ccd..a8ec09bd 100644
--- a/internal/webhook/warehouse/webhook_test.go
+++ b/internal/webhook/warehouse/webhook_test.go
@@ -490,8 +490,10 @@ func TestValidateChartSub(t *testing.T) {
 		assertions func(field.ErrorList)
 	}{
 		{
-			name: "invalid",
+			name: "invalid semverConstraint and oci repoURL with name",
 			sub: kargoapi.ChartSubscription{
+				RepoURL:          "oci://fake-url",
+				Name:             "should-not-be-here",
 				SemverConstraint: "bogus",
 			},
 			assertions: func(errs field.ErrorList) {
@@ -503,6 +505,33 @@ func TestValidateChartSub(t *testing.T) {
 							Field:    "chart.semverConstraint",
 							BadValue: "bogus",
 						},
+						{
+							Type:     field.ErrorTypeInvalid,
+							Field:    "chart.name",
+							BadValue: "should-not-be-here",
+							Detail:   "must be empty if repoURL starts with oci://",
+						},
+					},
+					errs,
+				)
+			},
+		},
+
+		{
+			name: "https repoURL without name",
+			sub: kargoapi.ChartSubscription{
+				RepoURL: "https://fake-url",
+			},
+			assertions: func(errs field.ErrorList) {
+				require.Equal(
+					t,
+					field.ErrorList{
+						{
+							Type:     field.ErrorTypeInvalid,
+							Field:    "chart.name",
+							BadValue: "",
+							Detail:   "must be non-empty if repoURL starts with http:// or https://",
+						},
 					},
 					errs,
 				)