feat(publish): add --annotation flag to docker compose publish

Signed-off-by: Mohataseem Khan <mohataseem89@gmail.com>
This commit is contained in:
Mohataseem Khan 2026-06-18 09:00:34 +00:00
parent 5fa0f40c61
commit 094bb807e3
6 changed files with 81 additions and 15 deletions

View file

@ -20,6 +20,7 @@ import (
"context"
"errors"
"strings"
"fmt"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"

View file

@ -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)
})
}

View file

@ -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 |
<!---MARKER_GEN_END-->

View file

@ -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"

View file

@ -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"

View file

@ -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