From 5fa0f40c610f9fbe2843da58082f60a110ff6994 Mon Sep 17 00:00:00 2001 From: Mohataseem Khan Date: Wed, 17 Jun 2026 17:09:49 +0000 Subject: [PATCH 1/2] WIP: compose publish docs --- cmd/compose/publish.go | 25 +++++++++++++++++++++++++ internal/oci/push.go | 12 +++++++++--- pkg/api/api.go | 2 ++ pkg/compose/publish.go | 2 +- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/cmd/compose/publish.go b/cmd/compose/publish.go index 6dba282ebc4..495521ecb7c 100644 --- a/cmd/compose/publish.go +++ b/cmd/compose/publish.go @@ -19,6 +19,7 @@ package compose import ( "context" "errors" + "strings" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -38,6 +39,7 @@ type publishOptions struct { assumeYes bool app bool insecureRegistry bool + annotations []string } func publishCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { @@ -59,6 +61,7 @@ func publishCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Ba flags.BoolVarP(&opts.assumeYes, "yes", "y", false, `Assume "yes" as answer to all prompts`) flags.BoolVar(&opts.app, "app", false, "Published compose application (includes referenced images)") flags.BoolVar(&opts.insecureRegistry, "insecure-registry", false, "Use insecure registry") + flags.StringArrayVar(&opts.annotations, "annotation", nil, "Add custom metadata to the published OCI artifact (format: key=value)") flags.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { // assumeYes was introduced by mistake as `--y` if name == "y" { @@ -92,11 +95,33 @@ func runPublish(ctx context.Context, dockerCli command.Cli, backendOptions *Back return errors.New("cannot publish compose file with local includes") } + annotations, err := parseAnnotations(opts.annotations) + if err != nil { + return err + } + return backend.Publish(ctx, project, repository, api.PublishOptions{ ResolveImageDigests: opts.resolveImageDigests || opts.app, Application: opts.app, OCIVersion: api.OCIVersion(opts.ociVersion), WithEnvironment: opts.withEnvironment, InsecureRegistry: opts.insecureRegistry, + Annotations: annotations, }) } + +//helper function +func parseAnnotations(raw []string) (map[string]string, error) { + if len(raw) == 0 { + return nil, nil + } + annotations := make(map[string]string, len(raw)) + for _, a := range raw { + key, value, ok := strings.Cut(a, "=") + if !ok || key == "" { + return nil, fmt.Errorf("invalid annotation %q: expected format key=value", a) + } + annotations[key] = value + } + return annotations, nil +} \ No newline at end of file diff --git a/internal/oci/push.go b/internal/oci/push.go index 1e2d0f2e95b..84808066350 100644 --- a/internal/oci/push.go +++ b/internal/oci/push.go @@ -194,15 +194,21 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) (v1.Desc return v1.Descriptor{}, nil, fmt.Errorf("unsupported OCI version: %s", ociCompat) } + annotations := map[string]string{ + "org.opencontainers.image.created": time.Now().Format(time.RFC3339), + } + for k, v := range extraAnnotations { + annotations[k] = v + } + manifest, err := json.Marshal(v1.Manifest{ Versioned: specs.Versioned{SchemaVersion: 2}, MediaType: v1.MediaTypeImageManifest, ArtifactType: artifactType, Config: config, Layers: layers, - Annotations: map[string]string{ - "org.opencontainers.image.created": time.Now().Format(time.RFC3339), - }, + Annotations: annotations, + }) if err != nil { return v1.Descriptor{}, nil, err diff --git a/pkg/api/api.go b/pkg/api/api.go index 1e84cca2bf7..48b67181d01 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -496,6 +496,8 @@ type PublishOptions struct { OCIVersion OCIVersion // Use plain HTTP to access registry. Should only be used for testing purpose InsecureRegistry bool + // Annotations are custom key/value pairs added to the published manifest + Annotations map[string]string } func (e Event) String() string { diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index 2e820cf8a5c..0b66f278466 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -99,7 +99,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re resolver := oci.NewResolver(s.configFile(), desktop.ProxyTransportFor(ctx, s.apiClient()), insecureRegistries...) - descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion) + descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion, options.Annotations) if err != nil { s.events.On(api.Resource{ ID: repository, From 094bb807e35c3a6fd39d00e78c87fc61d88fe4f5 Mon Sep 17 00:00:00 2001 From: Mohataseem Khan Date: Thu, 18 Jun 2026 09:00:34 +0000 Subject: [PATCH 2/2] feat(publish): add --annotation flag to docker compose publish Signed-off-by: Mohataseem Khan --- cmd/compose/publish.go | 1 + cmd/compose/publish_test.go | 42 +++++++++++++++++++ docs/reference/compose_publish.md | 17 ++++---- .../docker_compose_alpha_publish.yaml | 11 +++++ docs/reference/docker_compose_publish.yaml | 11 +++++ internal/oci/push.go | 14 +++---- 6 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 cmd/compose/publish_test.go diff --git a/cmd/compose/publish.go b/cmd/compose/publish.go index 495521ecb7c..e06f3008d64 100644 --- a/cmd/compose/publish.go +++ b/cmd/compose/publish.go @@ -20,6 +20,7 @@ import ( "context" "errors" "strings" + "fmt" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" diff --git a/cmd/compose/publish_test.go b/cmd/compose/publish_test.go new file mode 100644 index 00000000000..1539db76995 --- /dev/null +++ b/cmd/compose/publish_test.go @@ -0,0 +1,42 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestParseAnnotations(t *testing.T) { + t.Run("valid annotation", func(t *testing.T) { + result, err := parseAnnotations([]string{"foo=bar"}) + assert.NilError(t, err) + assert.DeepEqual(t, result, map[string]string{"foo": "bar"}) + }) + + t.Run("invalid annotation missing equals sign", func(t *testing.T) { + _, err := parseAnnotations([]string{"foobar"}) + assert.Error(t, err, `invalid annotation "foobar": expected format key=value`) + }) + + t.Run("empty slice", func(t *testing.T) { + result, err := parseAnnotations([]string{}) + assert.NilError(t, err) + assert.Check(t, result == nil) + }) +} diff --git a/docs/reference/compose_publish.md b/docs/reference/compose_publish.md index 9a82fc260a7..880b5fa24be 100644 --- a/docs/reference/compose_publish.md +++ b/docs/reference/compose_publish.md @@ -5,14 +5,15 @@ Publish compose application ### Options -| Name | Type | Default | Description | -|:--------------------------|:---------|:--------|:-------------------------------------------------------------------------------| -| `--app` | `bool` | | Published compose application (includes referenced images) | -| `--dry-run` | `bool` | | Execute command in dry run mode | -| `--oci-version` | `string` | | OCI image/artifact specification version (automatically determined by default) | -| `--resolve-image-digests` | `bool` | | Pin image tags to digests | -| `--with-env` | `bool` | | Include environment variables in the published OCI artifact | -| `-y`, `--yes` | `bool` | | Assume "yes" as answer to all prompts | +| Name | Type | Default | Description | +|:--------------------------|:--------------|:--------|:-------------------------------------------------------------------------------| +| `--annotation` | `stringArray` | | Add custom metadata to the published OCI artifact (format: key=value) | +| `--app` | `bool` | | Published compose application (includes referenced images) | +| `--dry-run` | `bool` | | Execute command in dry run mode | +| `--oci-version` | `string` | | OCI image/artifact specification version (automatically determined by default) | +| `--resolve-image-digests` | `bool` | | Pin image tags to digests | +| `--with-env` | `bool` | | Include environment variables in the published OCI artifact | +| `-y`, `--yes` | `bool` | | Assume "yes" as answer to all prompts | diff --git a/docs/reference/docker_compose_alpha_publish.yaml b/docs/reference/docker_compose_alpha_publish.yaml index 9059cbf4869..1686e14abed 100644 --- a/docs/reference/docker_compose_alpha_publish.yaml +++ b/docs/reference/docker_compose_alpha_publish.yaml @@ -5,6 +5,17 @@ usage: docker compose alpha publish [OPTIONS] REPOSITORY[:TAG] pname: docker compose alpha plink: docker_compose_alpha.yaml options: + - option: annotation + value_type: stringArray + default_value: '[]' + description: | + Add custom metadata to the published OCI artifact (format: key=value) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: app value_type: bool default_value: "false" diff --git a/docs/reference/docker_compose_publish.yaml b/docs/reference/docker_compose_publish.yaml index c3189d89c57..5947612d135 100644 --- a/docs/reference/docker_compose_publish.yaml +++ b/docs/reference/docker_compose_publish.yaml @@ -5,6 +5,17 @@ usage: docker compose publish [OPTIONS] REPOSITORY[:TAG] pname: docker compose plink: docker_compose.yaml options: + - option: annotation + value_type: stringArray + default_value: '[]' + description: | + Add custom metadata to the published OCI artifact (format: key=value) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: app value_type: bool default_value: "false" diff --git a/internal/oci/push.go b/internal/oci/push.go index 84808066350..5abc1fc70da 100644 --- a/internal/oci/push.go +++ b/internal/oci/push.go @@ -94,7 +94,7 @@ func DescriptorForEnvFile(path string, content []byte) v1.Descriptor { } } -func PushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) (v1.Descriptor, error) { +func PushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion, extraAnnotations map[string]string) (v1.Descriptor, error) { // Check if we need an extra empty layer for the manifest config if ociVersion == api.OCIVersion1_1 || ociVersion == "" { err := push(ctx, resolver, named, v1.DescriptorEmptyJSON) @@ -113,17 +113,17 @@ func PushManifest(ctx context.Context, resolver remotes.Resolver, named referenc if ociVersion != "" { // if a version was explicitly specified, use it - return createAndPushManifest(ctx, resolver, named, layerDescriptors, ociVersion) + return createAndPushManifest(ctx, resolver, named, layerDescriptors, ociVersion, extraAnnotations) } // try to push in the OCI 1.1 format but fallback to OCI 1.0 on 4xx errors // (other than auth) since it's most likely the result of the registry not // having support - descriptor, err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1) + descriptor, err := createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_1, extraAnnotations) var pushErr pusherrors.ErrUnexpectedStatus if errors.As(err, &pushErr) && isNonAuthClientError(pushErr.StatusCode) { // TODO(milas): show a warning here (won't work with logrus) - return createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_0) + return createAndPushManifest(ctx, resolver, named, layerDescriptors, api.OCIVersion1_0, extraAnnotations) } return descriptor, err } @@ -137,8 +137,8 @@ func push(ctx context.Context, resolver remotes.Resolver, ref reference.Named, d return Push(ctx, resolver, fullRef, descriptor) } -func createAndPushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion) (v1.Descriptor, error) { - descriptor, toPush, err := generateManifest(layers, ociVersion) +func createAndPushManifest(ctx context.Context, resolver remotes.Resolver, named reference.Named, layers []v1.Descriptor, ociVersion api.OCIVersion, extraAnnotations map[string]string) (v1.Descriptor, error) { + descriptor, toPush, err := generateManifest(layers, ociVersion, extraAnnotations) if err != nil { return v1.Descriptor{}, err } @@ -159,7 +159,7 @@ func isNonAuthClientError(statusCode int) bool { return !slices.Contains(clientAuthStatusCodes, statusCode) } -func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) (v1.Descriptor, []v1.Descriptor, error) { +func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion, extraAnnotations map[string]string) (v1.Descriptor, []v1.Descriptor, error) { var toPush []v1.Descriptor var config v1.Descriptor var artifactType string