From f3cc94268ba0d886fe5cfdd6ac605a8135447f76 Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Mon, 13 Jul 2026 14:06:11 -0700 Subject: [PATCH 1/5] Add --oci-strict-version flag to push Signed-off-by: Daniel Fox --- pkg/action/push.go | 10 ++++ pkg/cmd/push.go | 3 ++ pkg/pusher/ocipusher.go | 39 ++++++++++++++- pkg/pusher/ocipusher_test.go | 96 ++++++++++++++++++++++++++++++++++++ pkg/pusher/pusher.go | 10 ++++ 5 files changed, 157 insertions(+), 1 deletion(-) diff --git a/pkg/action/push.go b/pkg/action/push.go index 0c7148f65..9473443da 100644 --- a/pkg/action/push.go +++ b/pkg/action/push.go @@ -37,6 +37,7 @@ type Push struct { caFile string insecureSkipTLSVerify bool plainHTTP bool + ociStrictVersion bool out io.Writer } @@ -73,6 +74,14 @@ func WithPlainHTTP(plainHTTP bool) PushOpt { } } +// WithOCIStrictVersion configures whether the OCI tag is derived from the +// parsed/sanitized semver representation of the chart version. +func WithOCIStrictVersion(ociStrictVersion bool) PushOpt { + return func(p *Push) { + p.ociStrictVersion = ociStrictVersion + } +} + // WithPushOptWriter sets the registryOut field on the push configuration object. func WithPushOptWriter(out io.Writer) PushOpt { return func(p *Push) { @@ -100,6 +109,7 @@ func (p *Push) Run(chartRef string, remote string) (string, error) { pusher.WithTLSClientConfig(p.certFile, p.keyFile, p.caFile), pusher.WithInsecureSkipTLSVerify(p.insecureSkipTLSVerify), pusher.WithPlainHTTP(p.plainHTTP), + pusher.WithOCIStrictVersion(p.ociStrictVersion), }, } diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index f32ce92be..a5a5b2829 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -40,6 +40,7 @@ type registryPushOptions struct { caFile string insecureSkipTLSVerify bool plainHTTP bool + ociStrictVersion bool password string username string } @@ -84,6 +85,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { action.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), action.WithInsecureSkipTLSVerify(o.insecureSkipTLSVerify), action.WithPlainHTTP(o.plainHTTP), + action.WithOCIStrictVersion(o.ociStrictVersion), action.WithPushOptWriter(out)) client.Settings = settings output, err := client.Run(chartRef, remote) @@ -101,6 +103,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") f.BoolVar(&o.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart upload") f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload") + f.BoolVar(&o.ociStrictVersion, "oci-strict-version", false, "derive the OCI tag from the parsed/sanitized semver representation of the chart version") f.StringVar(&o.username, "username", "", "chart repository username where to locate the requested chart") f.StringVar(&o.password, "password", "", "chart repository password where to locate the requested chart") diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index 2a12e09b4..ecbad48c2 100644 --- a/pkg/pusher/ocipusher.go +++ b/pkg/pusher/ocipusher.go @@ -26,6 +26,8 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" + "helm.sh/helm/v4/internal/tlsutil" "helm.sh/helm/v4/pkg/chart/v2/loader" "helm.sh/helm/v4/pkg/registry" @@ -85,9 +87,26 @@ func (pusher *OCIPusher) push(chartRef, href string) error { pushOpts = append(pushOpts, registry.PushOptProvData(provBytes)) } + // Resolve the version used as the base of the OCI tag. With strict + // versioning enabled this is the sanitized semver form of the chart + // version; otherwise it is the raw chart version. The registry client + // still applies its usual tag transformations (e.g. replacing plus (+) + // signs with underscores) afterwards. + version, err := resolveOCITagVersion(meta.Metadata.Version, pusher.opts.ociStrictVersion) + if err != nil { + return err + } + + // The sanitized version may differ from the raw chart version, so relax + // the registry client's strict-mode assertion (which requires the tag to + // equal the raw chart version) when it does. + if version != meta.Metadata.Version { + pushOpts = append(pushOpts, registry.PushOptStrictMode(false)) + } + ref := fmt.Sprintf("%s:%s", path.Join(strings.TrimPrefix(href, registry.OCIScheme+"://"), meta.Metadata.Name), - meta.Metadata.Version) + version) // The time the chart was "created" is semantically the time the chart archive file was last written(modified) chartArchiveFileCreatedTime := stat.ModTime() @@ -97,6 +116,24 @@ func (pusher *OCIPusher) push(chartRef, href string) error { return err } +// resolveOCITagVersion returns the version string to use as the base of the OCI +// tag for a chart. When ociStrictVersion is false the raw chart version is +// returned unchanged. When it is true the version is parsed with semver and its +// sanitized string representation is returned, so that a canonical semver tag is +// produced regardless of how the version was written in Chart.yaml. +func resolveOCITagVersion(rawVersion string, ociStrictVersion bool) (string, error) { + if !ociStrictVersion { + return rawVersion, nil + } + + parsedVersion, err := semver.NewVersion(rawVersion) + if err != nil { + return "", fmt.Errorf("failed to parse chart version %q as semver: %w", rawVersion, err) + } + + return parsedVersion.String(), nil +} + // NewOCIPusher constructs a valid OCI client as a Pusher func NewOCIPusher(ops ...Option) (Pusher, error) { var client OCIPusher diff --git a/pkg/pusher/ocipusher_test.go b/pkg/pusher/ocipusher_test.go index 52536dfd2..af7a51a54 100644 --- a/pkg/pusher/ocipusher_test.go +++ b/pkg/pusher/ocipusher_test.go @@ -393,6 +393,102 @@ func TestOCIPusher_Push_ChartOperations(t *testing.T) { } } +func TestWithOCIStrictVersion(t *testing.T) { + p, err := NewOCIPusher(WithOCIStrictVersion(true)) + if err != nil { + t.Fatal(err) + } + + op, ok := p.(*OCIPusher) + if !ok { + t.Fatal("Expected NewOCIPusher to produce an *OCIPusher") + } + + if !op.opts.ociStrictVersion { + t.Error("Expected WithOCIStrictVersion(true) to set ociStrictVersion") + } + + // Defaults to false when the option is not supplied. + p, err = NewOCIPusher() + if err != nil { + t.Fatal(err) + } + if p.(*OCIPusher).opts.ociStrictVersion { + t.Error("Expected ociStrictVersion to default to false") + } +} + +func TestResolveOCITagVersion(t *testing.T) { + tests := []struct { + name string + rawVersion string + ociStrictVersion bool + want string + expectError bool + }{ + { + name: "strict disabled returns raw version unchanged", + rawVersion: "v1.2.3", + ociStrictVersion: false, + want: "v1.2.3", + }, + { + name: "strict disabled does not validate version", + rawVersion: "not-a-semver", + ociStrictVersion: false, + want: "not-a-semver", + }, + { + name: "strict strips leading v", + rawVersion: "v1.2.3", + ociStrictVersion: true, + want: "1.2.3", + }, + { + name: "strict leaves canonical version unchanged", + rawVersion: "1.2.3", + ociStrictVersion: true, + want: "1.2.3", + }, + { + name: "strict preserves prerelease", + rawVersion: "1.2.3-alpha.1", + ociStrictVersion: true, + want: "1.2.3-alpha.1", + }, + { + name: "strict preserves build metadata (sanitized to underscore later)", + rawVersion: "1.2.3+build.5", + ociStrictVersion: true, + want: "1.2.3+build.5", + }, + { + name: "strict errors on non-semver version", + rawVersion: "not-a-semver", + ociStrictVersion: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveOCITagVersion(tt.rawVersion, tt.ociStrictVersion) + if tt.expectError { + if err == nil { + t.Fatalf("Expected error for version %q but got none", tt.rawVersion) + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("resolveOCITagVersion(%q, %t) = %q, want %q", tt.rawVersion, tt.ociStrictVersion, got, tt.want) + } + }) + } +} + func TestOCIPusher_Push_MultipleOptions(t *testing.T) { chartPath := "../../pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz" diff --git a/pkg/pusher/pusher.go b/pkg/pusher/pusher.go index 8ce78b011..563ba2fa3 100644 --- a/pkg/pusher/pusher.go +++ b/pkg/pusher/pusher.go @@ -34,6 +34,7 @@ type options struct { caFile string insecureSkipTLSVerify bool plainHTTP bool + ociStrictVersion bool } // Option allows specifying various settings configurable by the user for overriding the defaults @@ -69,6 +70,15 @@ func WithPlainHTTP(plainHTTP bool) Option { } } +// WithOCIStrictVersion determines whether the OCI tag is derived from the +// parsed/sanitized semver representation of the chart version rather than the +// raw version string. +func WithOCIStrictVersion(ociStrictVersion bool) Option { + return func(opts *options) { + opts.ociStrictVersion = ociStrictVersion + } +} + // Pusher is an interface to support upload to the specified URL. type Pusher interface { // Push file content by url string From fbd649065cf2f0be74436670d9f2e2b7e8ad6110 Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Mon, 13 Jul 2026 14:35:28 -0700 Subject: [PATCH 2/5] Rename --oci-strict-version to --oci-normalize-version Signed-off-by: Daniel Fox --- pkg/action/push.go | 12 +-- pkg/cmd/push.go | 6 +- pkg/pusher/ocipusher.go | 61 ++++++++---- pkg/pusher/ocipusher_test.go | 179 +++++++++++++++++++++++++++-------- pkg/pusher/pusher.go | 15 +-- 5 files changed, 199 insertions(+), 74 deletions(-) diff --git a/pkg/action/push.go b/pkg/action/push.go index 9473443da..a8f0b3a7d 100644 --- a/pkg/action/push.go +++ b/pkg/action/push.go @@ -37,7 +37,7 @@ type Push struct { caFile string insecureSkipTLSVerify bool plainHTTP bool - ociStrictVersion bool + ociNormalizeVersion bool out io.Writer } @@ -74,11 +74,11 @@ func WithPlainHTTP(plainHTTP bool) PushOpt { } } -// WithOCIStrictVersion configures whether the OCI tag is derived from the -// parsed/sanitized semver representation of the chart version. -func WithOCIStrictVersion(ociStrictVersion bool) PushOpt { +// WithOCINormalizeVersion configures whether the OCI tag is derived from the +// canonical semver representation of the chart version. +func WithOCINormalizeVersion(ociNormalizeVersion bool) PushOpt { return func(p *Push) { - p.ociStrictVersion = ociStrictVersion + p.ociNormalizeVersion = ociNormalizeVersion } } @@ -109,7 +109,7 @@ func (p *Push) Run(chartRef string, remote string) (string, error) { pusher.WithTLSClientConfig(p.certFile, p.keyFile, p.caFile), pusher.WithInsecureSkipTLSVerify(p.insecureSkipTLSVerify), pusher.WithPlainHTTP(p.plainHTTP), - pusher.WithOCIStrictVersion(p.ociStrictVersion), + pusher.WithOCINormalizeVersion(p.ociNormalizeVersion), }, } diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index a5a5b2829..f67f3d969 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -40,7 +40,7 @@ type registryPushOptions struct { caFile string insecureSkipTLSVerify bool plainHTTP bool - ociStrictVersion bool + ociNormalizeVersion bool password string username string } @@ -85,7 +85,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { action.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), action.WithInsecureSkipTLSVerify(o.insecureSkipTLSVerify), action.WithPlainHTTP(o.plainHTTP), - action.WithOCIStrictVersion(o.ociStrictVersion), + action.WithOCINormalizeVersion(o.ociNormalizeVersion), action.WithPushOptWriter(out)) client.Settings = settings output, err := client.Run(chartRef, remote) @@ -103,7 +103,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") f.BoolVar(&o.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart upload") f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload") - f.BoolVar(&o.ociStrictVersion, "oci-strict-version", false, "derive the OCI tag from the parsed/sanitized semver representation of the chart version") + f.BoolVar(&o.ociNormalizeVersion, "oci-normalize-version", false, "push using the canonical semver form of the chart version as the OCI tag (e.g. v1.2.3 is pushed as 1.2.3)") f.StringVar(&o.username, "username", "", "chart repository username where to locate the requested chart") f.StringVar(&o.password, "password", "", "chart repository password where to locate the requested chart") diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index ecbad48c2..c66f67813 100644 --- a/pkg/pusher/ocipusher.go +++ b/pkg/pusher/ocipusher.go @@ -87,27 +87,24 @@ func (pusher *OCIPusher) push(chartRef, href string) error { pushOpts = append(pushOpts, registry.PushOptProvData(provBytes)) } - // Resolve the version used as the base of the OCI tag. With strict - // versioning enabled this is the sanitized semver form of the chart - // version; otherwise it is the raw chart version. The registry client - // still applies its usual tag transformations (e.g. replacing plus (+) - // signs with underscores) afterwards. - version, err := resolveOCITagVersion(meta.Metadata.Version, pusher.opts.ociStrictVersion) + // Build the OCI reference for the chart. When --oci-normalize-version is + // set (pusher.opts.ociNormalizeVersion) the tag is the canonical semver + // form of the chart version; otherwise it is the raw chart version. This + // flag is a separate concept from the registry client's own "strict mode" + // (relaxed below). + ref, relaxStrictMode, err := buildOCIReference(href, meta.Metadata.Name, meta.Metadata.Version, pusher.opts.ociNormalizeVersion) if err != nil { return err } - // The sanitized version may differ from the raw chart version, so relax - // the registry client's strict-mode assertion (which requires the tag to - // equal the raw chart version) when it does. - if version != meta.Metadata.Version { + // The registry client's strict mode asserts that the tag equals the raw + // chart version. Once the version has been canonicalized the tag no longer + // matches, so that assertion must be disabled. (This is unrelated to the + // --oci-normalize-version flag despite the similar "strict" wording.) + if relaxStrictMode { pushOpts = append(pushOpts, registry.PushOptStrictMode(false)) } - ref := fmt.Sprintf("%s:%s", - path.Join(strings.TrimPrefix(href, registry.OCIScheme+"://"), meta.Metadata.Name), - version) - // The time the chart was "created" is semantically the time the chart archive file was last written(modified) chartArchiveFileCreatedTime := stat.ModTime() pushOpts = append(pushOpts, registry.PushOptCreationTime(chartArchiveFileCreatedTime.Format(time.RFC3339))) @@ -116,18 +113,46 @@ func (pusher *OCIPusher) push(chartRef, href string) error { return err } +// buildOCIReference constructs the OCI reference used to push a chart and +// reports whether the registry client's strict mode must be relaxed for it. +// +// When ociNormalizeVersion is true the tag is the canonical semver form of the +// chart version (e.g. "v1.2.3" -> "1.2.3"); otherwise the raw chart version is +// used unchanged. The registry client still applies its usual tag +// transformations afterwards (e.g. replacing plus (+) signs with underscores). +// +// relaxStrictMode is true when the resulting tag differs from the raw chart +// version. The registry client's strict mode (registry.PushOptStrictMode) +// asserts the tag equals the raw chart version, so it must be disabled once the +// version has been canonicalized. This is separate from the option that drives +// ociNormalizeVersion despite the shared "strict" terminology. +func buildOCIReference(href, chartName, rawVersion string, ociNormalizeVersion bool) (ref string, relaxStrictMode bool, err error) { + version, err := resolveOCITagVersion(rawVersion, ociNormalizeVersion) + if err != nil { + return "", false, err + } + + ref = fmt.Sprintf("%s:%s", + path.Join(strings.TrimPrefix(href, registry.OCIScheme+"://"), chartName), + version) + + return ref, version != rawVersion, nil +} + // resolveOCITagVersion returns the version string to use as the base of the OCI -// tag for a chart. When ociStrictVersion is false the raw chart version is +// tag for a chart. When ociNormalizeVersion is false the raw chart version is // returned unchanged. When it is true the version is parsed with semver and its -// sanitized string representation is returned, so that a canonical semver tag is +// canonical string representation is returned, so that a canonical semver tag is // produced regardless of how the version was written in Chart.yaml. -func resolveOCITagVersion(rawVersion string, ociStrictVersion bool) (string, error) { - if !ociStrictVersion { +func resolveOCITagVersion(rawVersion string, ociNormalizeVersion bool) (string, error) { + if !ociNormalizeVersion { return rawVersion, nil } parsedVersion, err := semver.NewVersion(rawVersion) if err != nil { + // Defensive: the chart loader validates the version as semver before a + // push reaches this point, so a valid chart should never hit this path. return "", fmt.Errorf("failed to parse chart version %q as semver: %w", rawVersion, err) } diff --git a/pkg/pusher/ocipusher_test.go b/pkg/pusher/ocipusher_test.go index af7a51a54..4c16e28c8 100644 --- a/pkg/pusher/ocipusher_test.go +++ b/pkg/pusher/ocipusher_test.go @@ -393,8 +393,8 @@ func TestOCIPusher_Push_ChartOperations(t *testing.T) { } } -func TestWithOCIStrictVersion(t *testing.T) { - p, err := NewOCIPusher(WithOCIStrictVersion(true)) +func TestWithOCINormalizeVersion(t *testing.T) { + p, err := NewOCIPusher(WithOCINormalizeVersion(true)) if err != nil { t.Fatal(err) } @@ -404,8 +404,8 @@ func TestWithOCIStrictVersion(t *testing.T) { t.Fatal("Expected NewOCIPusher to produce an *OCIPusher") } - if !op.opts.ociStrictVersion { - t.Error("Expected WithOCIStrictVersion(true) to set ociStrictVersion") + if !op.opts.ociNormalizeVersion { + t.Error("Expected WithOCINormalizeVersion(true) to set ociNormalizeVersion") } // Defaults to false when the option is not supplied. @@ -413,66 +413,66 @@ func TestWithOCIStrictVersion(t *testing.T) { if err != nil { t.Fatal(err) } - if p.(*OCIPusher).opts.ociStrictVersion { - t.Error("Expected ociStrictVersion to default to false") + if p.(*OCIPusher).opts.ociNormalizeVersion { + t.Error("Expected ociNormalizeVersion to default to false") } } func TestResolveOCITagVersion(t *testing.T) { tests := []struct { - name string - rawVersion string - ociStrictVersion bool - want string - expectError bool + name string + rawVersion string + ociNormalizeVersion bool + want string + expectError bool }{ { - name: "strict disabled returns raw version unchanged", - rawVersion: "v1.2.3", - ociStrictVersion: false, - want: "v1.2.3", + name: "strict disabled returns raw version unchanged", + rawVersion: "v1.2.3", + ociNormalizeVersion: false, + want: "v1.2.3", }, { - name: "strict disabled does not validate version", - rawVersion: "not-a-semver", - ociStrictVersion: false, - want: "not-a-semver", + name: "strict disabled does not validate version", + rawVersion: "not-a-semver", + ociNormalizeVersion: false, + want: "not-a-semver", }, { - name: "strict strips leading v", - rawVersion: "v1.2.3", - ociStrictVersion: true, - want: "1.2.3", + name: "strict strips leading v", + rawVersion: "v1.2.3", + ociNormalizeVersion: true, + want: "1.2.3", }, { - name: "strict leaves canonical version unchanged", - rawVersion: "1.2.3", - ociStrictVersion: true, - want: "1.2.3", + name: "strict leaves canonical version unchanged", + rawVersion: "1.2.3", + ociNormalizeVersion: true, + want: "1.2.3", }, { - name: "strict preserves prerelease", - rawVersion: "1.2.3-alpha.1", - ociStrictVersion: true, - want: "1.2.3-alpha.1", + name: "strict preserves prerelease", + rawVersion: "1.2.3-alpha.1", + ociNormalizeVersion: true, + want: "1.2.3-alpha.1", }, { - name: "strict preserves build metadata (sanitized to underscore later)", - rawVersion: "1.2.3+build.5", - ociStrictVersion: true, - want: "1.2.3+build.5", + name: "strict preserves build metadata (sanitized to underscore later)", + rawVersion: "1.2.3+build.5", + ociNormalizeVersion: true, + want: "1.2.3+build.5", }, { - name: "strict errors on non-semver version", - rawVersion: "not-a-semver", - ociStrictVersion: true, - expectError: true, + name: "strict errors on non-semver version", + rawVersion: "not-a-semver", + ociNormalizeVersion: true, + expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := resolveOCITagVersion(tt.rawVersion, tt.ociStrictVersion) + got, err := resolveOCITagVersion(tt.rawVersion, tt.ociNormalizeVersion) if tt.expectError { if err == nil { t.Fatalf("Expected error for version %q but got none", tt.rawVersion) @@ -483,7 +483,104 @@ func TestResolveOCITagVersion(t *testing.T) { t.Fatalf("Unexpected error: %v", err) } if got != tt.want { - t.Errorf("resolveOCITagVersion(%q, %t) = %q, want %q", tt.rawVersion, tt.ociStrictVersion, got, tt.want) + t.Errorf("resolveOCITagVersion(%q, %t) = %q, want %q", tt.rawVersion, tt.ociNormalizeVersion, got, tt.want) + } + }) + } +} + +func TestBuildOCIReference(t *testing.T) { + tests := []struct { + name string + href string + chartName string + rawVersion string + ociNormalizeVersion bool + wantRef string + wantRelax bool + expectError bool + }{ + { + name: "normalize disabled uses raw version and keeps strict mode", + href: "oci://localhost:5000/charts", + chartName: "mychart", + rawVersion: "v1.2.3", + ociNormalizeVersion: false, + wantRef: "localhost:5000/charts/mychart:v1.2.3", + wantRelax: false, + }, + { + name: "normalize canonicalizes v-prefixed version and relaxes strict mode", + href: "oci://localhost:5000/charts", + chartName: "mychart", + rawVersion: "v1.2.3", + ociNormalizeVersion: true, + wantRef: "localhost:5000/charts/mychart:1.2.3", + wantRelax: true, + }, + { + name: "normalize leaves canonical version and keeps strict mode", + href: "oci://localhost:5000/charts", + chartName: "mychart", + rawVersion: "1.2.3", + ociNormalizeVersion: true, + wantRef: "localhost:5000/charts/mychart:1.2.3", + wantRelax: false, + }, + { + name: "normalize completes short version and relaxes strict mode", + href: "oci://localhost:5000/charts", + chartName: "mychart", + rawVersion: "1.2", + ociNormalizeVersion: true, + wantRef: "localhost:5000/charts/mychart:1.2.0", + wantRelax: true, + }, + { + name: "normalize with build metadata keeps strict mode", + href: "oci://localhost:5000/charts", + chartName: "mychart", + rawVersion: "1.2.3+build.5", + ociNormalizeVersion: true, + wantRef: "localhost:5000/charts/mychart:1.2.3+build.5", + wantRelax: false, + }, + { + name: "oci scheme prefix is trimmed from href", + href: "oci://registry.example.com/team/charts", + chartName: "mychart", + rawVersion: "1.2.3", + ociNormalizeVersion: false, + wantRef: "registry.example.com/team/charts/mychart:1.2.3", + wantRelax: false, + }, + { + name: "normalize errors on non-semver version", + href: "oci://localhost:5000/charts", + chartName: "mychart", + rawVersion: "not-a-semver", + ociNormalizeVersion: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref, relax, err := buildOCIReference(tt.href, tt.chartName, tt.rawVersion, tt.ociNormalizeVersion) + if tt.expectError { + if err == nil { + t.Fatalf("Expected error for version %q but got none", tt.rawVersion) + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if ref != tt.wantRef { + t.Errorf("buildOCIReference ref = %q, want %q", ref, tt.wantRef) + } + if relax != tt.wantRelax { + t.Errorf("buildOCIReference relaxStrictMode = %t, want %t", relax, tt.wantRelax) } }) } diff --git a/pkg/pusher/pusher.go b/pkg/pusher/pusher.go index 563ba2fa3..05685a39c 100644 --- a/pkg/pusher/pusher.go +++ b/pkg/pusher/pusher.go @@ -34,7 +34,7 @@ type options struct { caFile string insecureSkipTLSVerify bool plainHTTP bool - ociStrictVersion bool + ociNormalizeVersion bool } // Option allows specifying various settings configurable by the user for overriding the defaults @@ -70,12 +70,15 @@ func WithPlainHTTP(plainHTTP bool) Option { } } -// WithOCIStrictVersion determines whether the OCI tag is derived from the -// parsed/sanitized semver representation of the chart version rather than the -// raw version string. -func WithOCIStrictVersion(ociStrictVersion bool) Option { +// WithOCINormalizeVersion determines whether the OCI tag is derived from the +// canonical semver representation of the chart version rather than the raw +// version string. Note this is unrelated to the registry client's "strict +// mode" (registry.PushOptStrictMode); enabling it may in fact require that +// strict mode to be relaxed, since the canonical tag can differ from the raw +// chart version. +func WithOCINormalizeVersion(ociNormalizeVersion bool) Option { return func(opts *options) { - opts.ociStrictVersion = ociStrictVersion + opts.ociNormalizeVersion = ociNormalizeVersion } } From 15bf8d2c92cc440c935eae2f3ccf6be4fceb41e2 Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Mon, 13 Jul 2026 15:21:25 -0700 Subject: [PATCH 3/5] Fix grammar nit Signed-off-by: Daniel Fox --- pkg/pusher/pusher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pusher/pusher.go b/pkg/pusher/pusher.go index 05685a39c..f62de63d6 100644 --- a/pkg/pusher/pusher.go +++ b/pkg/pusher/pusher.go @@ -74,7 +74,7 @@ func WithPlainHTTP(plainHTTP bool) Option { // canonical semver representation of the chart version rather than the raw // version string. Note this is unrelated to the registry client's "strict // mode" (registry.PushOptStrictMode); enabling it may in fact require that -// strict mode to be relaxed, since the canonical tag can differ from the raw +// strict mode be relaxed, since the canonical tag can differ from the raw // chart version. func WithOCINormalizeVersion(ociNormalizeVersion bool) Option { return func(opts *options) { From d2177125f4f34d8a53bf72c33a67e709866c49c3 Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Mon, 13 Jul 2026 15:22:56 -0700 Subject: [PATCH 4/5] Rename 'strict' tests to 'normalize' Signed-off-by: Daniel Fox --- pkg/pusher/ocipusher_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/pusher/ocipusher_test.go b/pkg/pusher/ocipusher_test.go index 4c16e28c8..972096605 100644 --- a/pkg/pusher/ocipusher_test.go +++ b/pkg/pusher/ocipusher_test.go @@ -427,43 +427,43 @@ func TestResolveOCITagVersion(t *testing.T) { expectError bool }{ { - name: "strict disabled returns raw version unchanged", + name: "normalize disabled returns raw version unchanged", rawVersion: "v1.2.3", ociNormalizeVersion: false, want: "v1.2.3", }, { - name: "strict disabled does not validate version", + name: "normalize disabled does not validate version", rawVersion: "not-a-semver", ociNormalizeVersion: false, want: "not-a-semver", }, { - name: "strict strips leading v", + name: "normalize strips leading v", rawVersion: "v1.2.3", ociNormalizeVersion: true, want: "1.2.3", }, { - name: "strict leaves canonical version unchanged", + name: "normalize leaves canonical version unchanged", rawVersion: "1.2.3", ociNormalizeVersion: true, want: "1.2.3", }, { - name: "strict preserves prerelease", + name: "normalize preserves prerelease", rawVersion: "1.2.3-alpha.1", ociNormalizeVersion: true, want: "1.2.3-alpha.1", }, { - name: "strict preserves build metadata (sanitized to underscore later)", + name: "normalize preserves build metadata (sanitized to underscore later)", rawVersion: "1.2.3+build.5", ociNormalizeVersion: true, want: "1.2.3+build.5", }, { - name: "strict errors on non-semver version", + name: "normalize errors on non-semver version", rawVersion: "not-a-semver", ociNormalizeVersion: true, expectError: true, From 65014402b179b112715380597905a76d235d20ef Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Mon, 13 Jul 2026 15:27:20 -0700 Subject: [PATCH 5/5] Fix up some irrelevant comments Signed-off-by: Daniel Fox --- pkg/pusher/ocipusher.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index c66f67813..2fbb3ed5b 100644 --- a/pkg/pusher/ocipusher.go +++ b/pkg/pusher/ocipusher.go @@ -89,9 +89,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { // Build the OCI reference for the chart. When --oci-normalize-version is // set (pusher.opts.ociNormalizeVersion) the tag is the canonical semver - // form of the chart version; otherwise it is the raw chart version. This - // flag is a separate concept from the registry client's own "strict mode" - // (relaxed below). + // form of the chart version; otherwise it is the raw chart version. ref, relaxStrictMode, err := buildOCIReference(href, meta.Metadata.Name, meta.Metadata.Version, pusher.opts.ociNormalizeVersion) if err != nil { return err @@ -99,8 +97,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { // The registry client's strict mode asserts that the tag equals the raw // chart version. Once the version has been canonicalized the tag no longer - // matches, so that assertion must be disabled. (This is unrelated to the - // --oci-normalize-version flag despite the similar "strict" wording.) + // matches, so that assertion must be disabled. if relaxStrictMode { pushOpts = append(pushOpts, registry.PushOptStrictMode(false)) }