From 6cb3fd8702f9af85482d9556239756ba9304afc2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 14:31:52 -0500 Subject: [PATCH 1/4] feat(features): add `features info manifest` and `features info tags` subcommands Add separate subcommands for structured access to feature metadata, matching the official devcontainer CLI interface while keeping existing `features info` behavior and `--show-tags` flag intact. --- cmd/features/info.go | 3 + cmd/features/info_manifest.go | 90 ++++++++++++++++++++++++++++ cmd/features/info_manifest_test.go | 62 +++++++++++++++++++ cmd/features/info_tags.go | 73 ++++++++++++++++++++++ cmd/features/info_tags_test.go | 45 ++++++++++++++ cmd/features/info_test.go | 10 ++++ pkg/devcontainer/feature/features.go | 10 +++- 7 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 cmd/features/info_manifest.go create mode 100644 cmd/features/info_manifest_test.go create mode 100644 cmd/features/info_tags.go create mode 100644 cmd/features/info_tags_test.go diff --git a/cmd/features/info.go b/cmd/features/info.go index 194ab16e7..fe37e2f0e 100644 --- a/cmd/features/info.go +++ b/cmd/features/info.go @@ -64,6 +64,9 @@ to display metadata.`, &cmd.ShowDependencies, "show-dependencies", false, "Show declared dependencies", ) + infoCmd.AddCommand(NewInfoManifestCmd(globalFlags)) + infoCmd.AddCommand(NewInfoTagsCmd(globalFlags)) + return infoCmd } diff --git a/cmd/features/info_manifest.go b/cmd/features/info_manifest.go new file mode 100644 index 000000000..195c8066c --- /dev/null +++ b/cmd/features/info_manifest.go @@ -0,0 +1,90 @@ +package features + +import ( + "fmt" + "os" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/spf13/cobra" +) + +type InfoManifestCmd struct { + *flags.GlobalFlags + + Output string +} + +func NewInfoManifestCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &InfoManifestCmd{GlobalFlags: globalFlags} + manifestCmd := &cobra.Command{ + Use: "manifest ", + Short: "Display the OCI manifest for a published feature", + Long: `Fetch and display the raw OCI manifest for a published dev container feature. + +Accepts a feature ID like ghcr.io/devcontainers/features/go or +ghcr.io/devcontainers/features/go:1 and outputs the OCI image manifest.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + return cmd.Run(args[0]) + }, + } + + manifestCmd.Flags().StringVar(&cmd.Output, "output", "json", "Output format (text or json)") + + return manifestCmd +} + +func (cmd *InfoManifestCmd) Run(featureID string) error { + if err := validateOutputFormat(cmd.Output); err != nil { + return err + } + + ref, err := name.ParseReference(featureID) + if err != nil { + return fmt.Errorf("invalid feature reference %q: %w", featureID, err) + } + + manifest, err := feature.FetchOCIManifest(ref) + if err != nil { + return fmt.Errorf("fetch manifest: %w", err) + } + + if cmd.Output == outputJSON { + return writeJSON(os.Stdout, manifest) + } + return cmd.printText(manifest) +} + +func (cmd *InfoManifestCmd) printText(manifest *v1.Manifest) error { + w := os.Stdout + _, _ = fmt.Fprintf(w, "Schema Version: %d\n", manifest.SchemaVersion) + _, _ = fmt.Fprintf(w, "Media Type: %s\n", manifest.MediaType) + + _, _ = fmt.Fprintf(w, "\nConfig:\n") + _, _ = fmt.Fprintf(w, " Media Type: %s\n", manifest.Config.MediaType) + _, _ = fmt.Fprintf(w, " Digest: %s\n", manifest.Config.Digest) + _, _ = fmt.Fprintf(w, " Size: %d\n", manifest.Config.Size) + + if len(manifest.Layers) > 0 { + _, _ = fmt.Fprintf(w, "\nLayers:\n") + for i, layer := range manifest.Layers { + _, _ = fmt.Fprintf(w, " [%d] Media Type: %s\n", i, layer.MediaType) + _, _ = fmt.Fprintf(w, " Digest: %s\n", layer.Digest) + _, _ = fmt.Fprintf(w, " Size: %d\n", layer.Size) + } + } + + if len(manifest.Annotations) > 0 { + _, _ = fmt.Fprintf(w, "\nAnnotations:\n") + for k, v := range manifest.Annotations { + _, _ = fmt.Fprintf(w, " %s: %s\n", k, v) + } + } + + return nil +} diff --git a/cmd/features/info_manifest_test.go b/cmd/features/info_manifest_test.go new file mode 100644 index 000000000..a05910dc7 --- /dev/null +++ b/cmd/features/info_manifest_test.go @@ -0,0 +1,62 @@ +package features + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoManifestCmd_FlagDefaults(t *testing.T) { + cmd := NewInfoManifestCmd(nil) + + outputFlag := cmd.Flags().Lookup("output") + require.NotNil(t, outputFlag) + assert.Equal(t, "json", outputFlag.DefValue) +} + +func TestInfoManifestCmd_RequiresExactlyOneArg(t *testing.T) { + cmd := NewInfoManifestCmd(nil) + assert.NotNil(t, cmd.Args) + + tests := []struct { + name string + args []string + wantErr bool + }{ + {name: "no args", args: []string{}, wantErr: true}, + {name: "one arg", args: []string{"ghcr.io/devcontainers/features/go"}, wantErr: false}, + {name: "two args", args: []string{"a", "b"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cmd.Args(cmd, tt.args) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestInfoManifestCmd_InvalidOutputFormat(t *testing.T) { + cmd := &InfoManifestCmd{Output: "xml"} + err := cmd.Run("ghcr.io/devcontainers/features/go:1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestInfoManifestCmd_InvalidFeatureReference(t *testing.T) { + cmd := &InfoManifestCmd{Output: outputJSON} + err := cmd.Run("not a valid reference!!!") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid feature reference") +} + +func TestInfoManifestCmd_CommandMetadata(t *testing.T) { + cmd := NewInfoManifestCmd(nil) + assert.Equal(t, "manifest ", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) +} diff --git a/cmd/features/info_tags.go b/cmd/features/info_tags.go new file mode 100644 index 000000000..d79bd7788 --- /dev/null +++ b/cmd/features/info_tags.go @@ -0,0 +1,73 @@ +package features + +import ( + "fmt" + "os" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" +) + +type InfoTagsCmd struct { + *flags.GlobalFlags + + Output string +} + +type tagsOutput struct { + Tags []string `json:"tags"` +} + +func NewInfoTagsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &InfoTagsCmd{GlobalFlags: globalFlags} + tagsCmd := &cobra.Command{ + Use: "tags ", + Short: "List available tags for a published feature", + Long: `List all available tags from the registry for a published dev container feature. + +Accepts a feature ID like ghcr.io/devcontainers/features/go and +queries the registry for all available image tags.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + return cmd.Run(args[0]) + }, + } + + tagsCmd.Flags().StringVar(&cmd.Output, "output", "text", "Output format (text or json)") + + return tagsCmd +} + +func (cmd *InfoTagsCmd) Run(featureID string) error { + if err := validateOutputFormat(cmd.Output); err != nil { + return err + } + + ref, err := name.ParseReference(featureID) + if err != nil { + return fmt.Errorf("invalid feature reference %q: %w", featureID, err) + } + + tags, err := listTags(ref) + if err != nil { + return fmt.Errorf("list tags: %w", err) + } + + if cmd.Output == outputJSON { + return writeJSON(os.Stdout, &tagsOutput{Tags: tags}) + } + + w := os.Stdout + if len(tags) == 0 { + _, _ = fmt.Fprintln(w, "No tags found.") + return nil + } + + _, _ = fmt.Fprintln(w, "Available Tags:") + _, _ = fmt.Fprintf(w, " %s\n", strings.Join(tags, ", ")) + return nil +} diff --git a/cmd/features/info_tags_test.go b/cmd/features/info_tags_test.go new file mode 100644 index 000000000..962553c97 --- /dev/null +++ b/cmd/features/info_tags_test.go @@ -0,0 +1,45 @@ +package features + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoTagsCmd_FlagDefaults(t *testing.T) { + cmd := NewInfoTagsCmd(nil) + + outputFlag := cmd.Flags().Lookup("output") + require.NotNil(t, outputFlag) + assert.Equal(t, outputText, outputFlag.DefValue) +} + +func TestInfoTagsCmd_RequiresExactlyOneArg(t *testing.T) { + cmd := NewInfoTagsCmd(nil) + assert.NotNil(t, cmd.Args) + assert.Error(t, cmd.Args(cmd, nil)) + assert.NoError(t, cmd.Args(cmd, []string{"ghcr.io/devcontainers/features/go"})) + assert.Error(t, cmd.Args(cmd, []string{"first", "second"})) +} + +func TestInfoTagsCmd_InvalidOutputFormat(t *testing.T) { + cmd := &InfoTagsCmd{Output: "csv"} + err := cmd.Run("ghcr.io/devcontainers/features/go:1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestInfoTagsCmd_InvalidFeatureReference(t *testing.T) { + cmd := &InfoTagsCmd{Output: outputText} + err := cmd.Run("not a valid reference!!!") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid feature reference") +} + +func TestInfoTagsCmd_CommandMetadata(t *testing.T) { + cmd := NewInfoTagsCmd(nil) + assert.Equal(t, "tags ", cmd.Use) + assert.Contains(t, cmd.Short, "tags") + assert.NotEmpty(t, cmd.Long) +} diff --git a/cmd/features/info_test.go b/cmd/features/info_test.go index 232757f46..d311657b5 100644 --- a/cmd/features/info_test.go +++ b/cmd/features/info_test.go @@ -57,3 +57,13 @@ func TestInfoCmd_InvalidFeatureReference(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "invalid feature reference") } + +func TestInfoCmd_HasSubcommands(t *testing.T) { + cmd := NewInfoCmd(nil) + subcommands := make(map[string]bool) + for _, sub := range cmd.Commands() { + subcommands[sub.Name()] = true + } + assert.True(t, subcommands["manifest"], "info should have 'manifest' subcommand") + assert.True(t, subcommands["tags"], "info should have 'tags' subcommand") +} diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index 1abb2c014..97a0cc394 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -192,8 +192,14 @@ func pullOCIImage(ref name.Reference) (v1.Image, error) { return img, nil } -// PullFeatureToTemp pulls an OCI feature image and extracts it to a temporary folder. -// Returns the path to the extracted feature folder. +func FetchOCIManifest(ref name.Reference) (*v1.Manifest, error) { + img, err := pullOCIImage(ref) + if err != nil { + return nil, err + } + return img.Manifest() +} + func PullFeatureToTemp(ref name.Reference, id string) (string, error) { featureFolder, err := getFeaturesTempFolder(id) if err != nil { From 0718774b99bd662677a30875737f354a85bf8ca2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 14:50:13 -0500 Subject: [PATCH 2/4] test(e2e): add e2e tests for `features info manifest` and `features info tags` subcommands --- e2e/tests/features/features.go | 184 +++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index 16680ba59..d83b58244 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -300,6 +300,190 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { gomega.Expect(stdout).To(gomega.ContainSubstring("1.0.0")) gomega.Expect(stdout).To(gomega.ContainSubstring("2.0.0")) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.Describe("manifest subcommand", func() { + ginkgo.It("returns valid OCI manifest JSON by default", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRef := regHost + "/test/features/go:1.0.0" + + pushFeatureWithAnnotations(featureRef, map[string]string{ + "org.opencontainers.image.title": "Go", + }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "manifest", featureRef, + }) + framework.ExpectNoError(err) + + var manifest map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &manifest)).To(gomega.Succeed()) + gomega.Expect(manifest).To(gomega.HaveKey("schemaVersion")) + gomega.Expect(manifest).To(gomega.HaveKey("mediaType")) + gomega.Expect(manifest).To(gomega.HaveKey("config")) + gomega.Expect(manifest).To(gomega.HaveKey("layers")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("returns text output with --output=text", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRef := regHost + "/test/features/go:1.0.0" + + pushFeatureWithAnnotations(featureRef, nil) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "manifest", featureRef, "--output", "text", + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("Schema Version:")) + gomega.Expect(stdout).To(gomega.ContainSubstring("Media Type:")) + gomega.Expect(stdout).To(gomega.ContainSubstring("Config:")) + gomega.Expect(stdout).To(gomega.ContainSubstring("Layers:")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("includes annotations in manifest JSON", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRef := regHost + "/test/features/go:1.0.0" + + pushFeatureWithAnnotations(featureRef, map[string]string{ + "org.opencontainers.image.title": "Go", + "org.opencontainers.image.version": "1.0.0", + }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "manifest", featureRef, + }) + framework.ExpectNoError(err) + + var manifest map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &manifest)).To(gomega.Succeed()) + gomega.Expect(manifest).To(gomega.HaveKey("annotations")) + annotations, ok := manifest["annotations"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(annotations).To(gomega.HaveKeyWithValue( + "org.opencontainers.image.title", "Go", + )) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails with invalid feature reference", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + _, stderr, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "manifest", "INVALID:///ref", + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stderr).To(gomega.ContainSubstring("invalid feature reference")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails with non-existent feature", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRef := regHost + "/test/features/nonexistent:1.0.0" + + _, stderr, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "manifest", featureRef, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stderr).To(gomega.ContainSubstring("fetch manifest")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) + + ginkgo.Describe("tags subcommand", func() { + ginkgo.It("lists available tags in text format", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/features/go" + + pushFeatureWithAnnotations(featureRepo+":1.0.0", nil) + pushFeatureWithAnnotations(featureRepo+":2.0.0", nil) + pushFeatureWithAnnotations(featureRepo+":latest", nil) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "tags", featureRepo + ":1.0.0", + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("Available Tags:")) + gomega.Expect(stdout).To(gomega.ContainSubstring("1.0.0")) + gomega.Expect(stdout).To(gomega.ContainSubstring("2.0.0")) + gomega.Expect(stdout).To(gomega.ContainSubstring("latest")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("outputs JSON when --output=json is specified", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/features/node" + + pushFeatureWithAnnotations(featureRepo+":1.0.0", nil) + pushFeatureWithAnnotations(featureRepo+":2.0.0", nil) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "tags", featureRepo + ":1.0.0", + "--output", "json", + }) + framework.ExpectNoError(err) + + var result map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) + gomega.Expect(result).To(gomega.HaveKey("tags")) + tags, ok := result["tags"].([]any) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(tags).To(gomega.ContainElement("1.0.0")) + gomega.Expect(tags).To(gomega.ContainElement("2.0.0")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("shows message when no tags are found", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + + _, stderr, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "tags", + regHost + "/test/features/empty:latest", + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stderr).To(gomega.ContainSubstring("list tags")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It("fails with invalid feature reference", func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + _, stderr, err := f.ExecCommandCapture(ctx, []string{ + "features", "info", "tags", "INVALID:///ref", + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stderr).To(gomega.ContainSubstring("invalid feature reference")) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + }) }) }) From f1977e55c702611e3d2eb859eea2f851a00d91cc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 15:00:51 -0500 Subject: [PATCH 3/4] fix(e2e): extract repeated subcommand strings to constants for goconst --- e2e/tests/features/features.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index d83b58244..5da6514f4 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -24,6 +24,11 @@ import ( "github.com/onsi/gomega" ) +const ( + subCmdManifest = "manifest" + subCmdTags = "tags" +) + var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { var initialDir string @@ -316,7 +321,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { }) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "manifest", featureRef, + "features", "info", subCmdManifest, featureRef, }) framework.ExpectNoError(err) @@ -340,7 +345,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { pushFeatureWithAnnotations(featureRef, nil) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "manifest", featureRef, "--output", "text", + "features", "info", subCmdManifest, featureRef, "--output", "text", }) framework.ExpectNoError(err) @@ -365,7 +370,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { }) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "manifest", featureRef, + "features", "info", subCmdManifest, featureRef, }) framework.ExpectNoError(err) @@ -383,7 +388,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { f := framework.NewDefaultFramework(initialDir + "/bin") _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "manifest", "INVALID:///ref", + "features", "info", subCmdManifest, "INVALID:///ref", }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stderr).To(gomega.ContainSubstring("invalid feature reference")) @@ -399,7 +404,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { featureRef := regHost + "/test/features/nonexistent:1.0.0" _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "manifest", featureRef, + "features", "info", subCmdManifest, featureRef, }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stderr).To(gomega.ContainSubstring("fetch manifest")) @@ -421,7 +426,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { pushFeatureWithAnnotations(featureRepo+":latest", nil) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "tags", featureRepo + ":1.0.0", + "features", "info", subCmdTags, featureRepo + ":1.0.0", }) framework.ExpectNoError(err) @@ -444,7 +449,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { pushFeatureWithAnnotations(featureRepo+":2.0.0", nil) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "tags", featureRepo + ":1.0.0", + "features", "info", subCmdTags, featureRepo + ":1.0.0", "--output", "json", }) framework.ExpectNoError(err) @@ -467,7 +472,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { regHost := strings.TrimPrefix(srv.URL, "http://") _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "tags", + "features", "info", subCmdTags, regHost + "/test/features/empty:latest", }) gomega.Expect(err).To(gomega.HaveOccurred()) @@ -478,7 +483,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { f := framework.NewDefaultFramework(initialDir + "/bin") _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", "tags", "INVALID:///ref", + "features", "info", subCmdTags, "INVALID:///ref", }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stderr).To(gomega.ContainSubstring("invalid feature reference")) From b3f39ad3a14ccaf0ef0fdc8197e6e7fc3e46159d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 16:23:13 -0500 Subject: [PATCH 4/4] fix(e2e): use truly invalid reference in features info error tests The string "INVALID:///ref" was parsed as a valid OCI reference with host "INVALID", causing a DNS error instead of the expected parse error. --- e2e/tests/features/features.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index 5da6514f4..f9ffdcb0b 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -388,7 +388,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { f := framework.NewDefaultFramework(initialDir + "/bin") _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", subCmdManifest, "INVALID:///ref", + "features", "info", subCmdManifest, "not a valid ref!!!", }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stderr).To(gomega.ContainSubstring("invalid feature reference")) @@ -483,7 +483,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { f := framework.NewDefaultFramework(initialDir + "/bin") _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", subCmdTags, "INVALID:///ref", + "features", "info", subCmdTags, "not a valid ref!!!", }) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(stderr).To(gomega.ContainSubstring("invalid feature reference"))