Repository: docker/compose (go)

Issue:
[BUG] Building images not visible in progress events, *except* in dry-run mode
### Description

When running `docker compose --progress plain up --dry-run --detach`, you can see build events:
```
 DRY-RUN MODE -    build service foobar 
 DRY-RUN MODE -  ==> ==> writing image dryRun-8843d7f92416211de9ebb963ff4ce28125932878 
 DRY-RUN MODE -  ==> ==> naming to my-python 
 DRY-RUN MODE -  Network compose_default  Creating
 DRY-RUN MODE -  Network compose_default  Created
 DRY-RUN MODE -  Container compose-foobar-2  Creating
 DRY-RUN MODE -  Container compose-foobar-1  Creating
 DRY-RUN MODE -  Container compose-foobar-2  Created
 DRY-RUN MODE -  Container compose-foobar-1  Created
 DRY-RUN MODE -  Container ompose-foobar-2  Starting
 DRY-RUN MODE -  Container ompose-foobar-2  Started
 DRY-RUN MODE -  Container ompose-foobar-1  Starting
 DRY-RUN MODE -  Container ompose-foobar-1  Started
```
(The missing `c` is #11402.)

If you run the same without `--dry-run`, you don't:
```
 Network compose_default  Creating
 Network compose_default  Created
 Container compose-foobar-2  Creating
 Container compose-foobar-1  Creating
 Container compose-foobar-1  Created
 Container compose-foobar-2  Created
 Container compose-foobar-1  Starting
 Container compose-foobar-1  Started
 Container compose-foobar-2  Starting
 Container compose-foobar-2  Started
```

### Steps To Reproduce

See description.

### Compose Version

_No response_

### Docker Environment

_No response_

### Anything else?

_No response_

Pull request description:
Emit events for building images
**What I did**
Right now `docker compose build` or `docker compose up --build` does not emit events. The only way to notice that something was built is by looking at stdout. The only case events are written (to stderr) is when using BuildKit in dry-mode (there's special code to emit some fake events in that case).

This PR makes building emit proper events (one before and one after building). This improves using `docker compose` from other programs. (Especially together with #11478.)

**Related issue**
Fixes #11403.

**(not mandatory) A picture of a cute animal, if possible in relation to what you did**
![A cute cat with rather sharp claws, watch out when petting her](https://spielwiese.fontein.de/photos/2015/05/cats-a-10.jpeg)


Patch:
diff --git a/.github/stale.yml b/.github/stale.yml
index a461723c3..c14cb1291 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -1,7 +1,7 @@
 # Configuration for probot-stale - https://github.com/probot/stale
 
 # Number of days of inactivity before an Issue or Pull Request becomes stale
-daysUntilStale: 180
+daysUntilStale: 90
 
 # Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
 # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
diff --git a/MAINTAINERS b/MAINTAINERS
index 3d3778766..a7810eccd 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -23,6 +23,7 @@
 
 		people = [
 			"glours",
+			"jhrotko",
 			"milas",
 			"ndeloof",
 			"nicksieger",
@@ -72,6 +73,11 @@
 	Email = "guillaume.tardif@docker.com"
 	GitHub = "gtardif"
 
+	[people.jhrotko]
+	Name = "Joana Hrotko"
+	Email = "joana.hrotko@docker.com"
+	Github = "jhrotko"
+
 	[people.laurazard]
 	Name = "Laura Brehm"
 	Email = "laura.brehm@docker.com"
diff --git a/cmd/compose/config.go b/cmd/compose/config.go
index 52e10fec2..5e1dd3ffd 100644
--- a/cmd/compose/config.go
+++ b/cmd/compose/config.go
@@ -279,6 +279,22 @@ func formatModel(model map[string]any, format string) (content []byte, err error
 }
 
 func runServices(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
+	if opts.noInterpolate {
+		// we can't use ToProject, so the model we render here is only partially resolved
+		data, err := opts.ToModel(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
+		if err != nil {
+			return err
+		}
+
+		if _, ok := data["services"]; ok {
+			for serviceName := range data["services"].(map[string]any) {
+				_, _ = fmt.Fprintln(dockerCli.Out(), serviceName)
+			}
+		}
+
+		return nil
+	}
+
 	project, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
 	if err != nil {
 		return err
@@ -287,6 +303,7 @@ func runServices(ctx context.Context, dockerCli command.Cli, opts configOptions)
 		_, _ = fmt.Fprintln(dockerCli.Out(), serviceName)
 		return nil
 	})
+
 	return err
 }
 
diff --git a/go.mod b/go.mod
index 0ba399cf5..540fdb008 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/Microsoft/go-winio v0.6.2
 	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
 	github.com/buger/goterm v1.0.4
-	github.com/compose-spec/compose-go/v2 v2.4.1
+	github.com/compose-spec/compose-go/v2 v2.4.4
 	github.com/containerd/containerd v1.7.23
 	github.com/containerd/platforms v0.2.1
 	github.com/davecgh/go-spew v1.1.1
@@ -52,8 +52,8 @@ require (
 	go.uber.org/goleak v1.3.0
 	go.uber.org/mock v0.5.0
 	golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
-	golang.org/x/sync v0.8.0
-	golang.org/x/sys v0.26.0
+	golang.org/x/sync v0.9.0
+	golang.org/x/sys v0.27.0
 	google.golang.org/grpc v1.67.1
 	gopkg.in/yaml.v3 v3.0.1
 	gotest.tools/v3 v3.5.1
diff --git a/go.sum b/go.sum
index fc205b6e3..8e8fd639f 100644
--- a/go.sum
+++ b/go.sum
@@ -85,8 +85,8 @@ github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20 h1:N+3sFI5GUjRKBi+i0Tx
 github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
-github.com/compose-spec/compose-go/v2 v2.4.1 h1:tEg6Qn/9LZnKg42fZlFmxN4lxSqnCvsiG5TXnxzvI4c=
-github.com/compose-spec/compose-go/v2 v2.4.1/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
+github.com/compose-spec/compose-go/v2 v2.4.4 h1:cvHBl5Jf1iNBmRrZCICmHvaoskYc1etTPEMLKVwokAY=
+github.com/compose-spec/compose-go/v2 v2.4.4/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
 github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
 github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
@@ -560,8 +560,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
+golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -582,8 +582,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
-golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
+golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/internal/ocipush/push.go b/internal/ocipush/push.go
index d9a7f6ec0..0296f67da 100644
--- a/internal/ocipush/push.go
+++ b/internal/ocipush/push.go
@@ -178,7 +178,6 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]Pusha
 	case api.OCIVersion1_1:
 		config = v1.DescriptorEmptyJSON
 		artifactType = ComposeProjectArtifactType
-		config.ArtifactType = artifactType
 		// N.B. the descriptor has the data embedded in it
 		toPush = append(toPush, Pushable{Descriptor: config, Data: make([]byte, len(config.Data))})
 	default:
diff --git a/pkg/compose/build.go b/pkg/compose/build.go
index d54b27ec2..fafeb9c2f 100644
--- a/pkg/compose/build.go
+++ b/pkg/compose/build.go
@@ -158,11 +158,16 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
 		}
 		service := serviceToBuild.service
 
+		cw := progress.ContextWriter(ctx)
+		serviceName := fmt.Sprintf("Service %s", name)
+
 		if !buildkitEnabled {
+			cw.Event(progress.BuildingEvent(serviceName))
 			id, err := s.doBuildClassic(ctx, project, service, options)
 			if err != nil {
 				return err
 			}
+			cw.Event(progress.BuiltEvent(serviceName))
 			builtDigests[getServiceIndex(name)] = id
 
 			if options.Push {
@@ -180,10 +185,12 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
 			return err
 		}
 
+		cw.Event(progress.BuildingEvent(serviceName))
 		digest, err := s.doBuildBuildkit(ctx, name, buildOptions, w, nodes)
 		if err != nil {
 			return err
 		}
+		cw.Event(progress.BuiltEvent(serviceName))
 		builtDigests[getServiceIndex(name)] = digest
 
 		return nil
@@ -274,7 +281,7 @@ func (s *composeService) getLocalImagesDigests(ctx context.Context, project *typ
 			imageNames = append(imageNames, imgName)
 		}
 	}
-	imgs, err := s.getImages(ctx, imageNames)
+	imgs, err := s.getImageSummaries(ctx, imageNames)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/compose/build_buildkit.go b/pkg/compose/build_buildkit.go
index b530d463c..4b28b292d 100644
--- a/pkg/compose/build_buildkit.go
+++ b/pkg/compose/build_buildkit.go
@@ -70,11 +70,6 @@ func (s composeService) dryRunBuildResponse(ctx context.Context, name string, op
 	w := progress.ContextWriter(ctx)
 	buildResponse := map[string]*client.SolveResponse{}
 	dryRunUUID := fmt.Sprintf("dryRun-%x", sha1.Sum([]byte(name)))
-	w.Event(progress.Event{
-		ID:     " ",
-		Status: progress.Done,
-		Text:   fmt.Sprintf("build service %s", name),
-	})
 	w.Event(progress.Event{
 		ID:     "==>",
 		Status: progress.Done,
diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go
index 2c2c85f82..d57c13b8b 100644
--- a/pkg/compose/convergence.go
+++ b/pkg/compose/convergence.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -136,16 +137,18 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
 		ni, erri := strconv.Atoi(containers[i].Labels[api.ContainerNumberLabel])
 		nj, errj := strconv.Atoi(containers[j].Labels[api.ContainerNumberLabel])
 		if erri == nil && errj == nil {
-			return ni < nj
+			return ni > nj
 		}
 
 		// If we don't get a container number (?) just sort by creation date
 		return containers[i].Created < containers[j].Created
 	})
 
+	slices.Reverse(containers)
 	for i, container := range containers {
 		if i >= expected {
 			// Scale Down
+			// As we sorted containers, obsolete ones and/or highest number will be removed
 			container := container
 			traceOpts := append(tracing.ServiceOptions(service), tracing.ContainerOptions(container)...)
 			eg.Go(tracing.SpanWrapFuncForErrGroup(ctx, "service/scale/down", traceOpts, func(ctx context.Context) error {
diff --git a/pkg/compose/hash.go b/pkg/compose/hash.go
index e6a189bd8..f284a84e0 100644
--- a/pkg/compose/hash.go
+++ b/pkg/compose/hash.go
@@ -33,6 +33,7 @@ func ServiceHash(o types.ServiceConfig) (string, error) {
 		o.Deploy.Replicas = nil
 	}
 	o.DependsOn = nil
+	o.Profiles = nil
 
 	bytes, err := json.Marshal(o)
 	if err != nil {
diff --git a/pkg/compose/images.go b/pkg/compose/images.go
index a0c9044b8..88fd56ba6 100644
--- a/pkg/compose/images.go
+++ b/pkg/compose/images.go
@@ -55,20 +55,19 @@ func (s *composeService) Images(ctx context.Context, projectName string, options
 		containers = allContainers
 	}
 
-	imageIDs := []string{}
-	// aggregate image IDs
+	images := []string{}
 	for _, c := range containers {
-		if !utils.StringContains(imageIDs, c.ImageID) {
-			imageIDs = append(imageIDs, c.ImageID)
+		if !utils.StringContains(images, c.Image) {
+			images = append(images, c.Image)
 		}
 	}
-	images, err := s.getImages(ctx, imageIDs)
+	imageSummaries, err := s.getImageSummaries(ctx, images)
 	if err != nil {
 		return nil, err
 	}
 	summary := make([]api.ImageSummary, len(containers))
 	for i, container := range containers {
-		img, ok := images[container.ImageID]
+		img, ok := imageSummaries[container.Image]
 		if !ok {
 			return nil, fmt.Errorf("failed to retrieve image for container %s", getCanonicalContainerName(container))
 		}
@@ -79,34 +78,32 @@ func (s *composeService) Images(ctx context.Context, projectName string, options
 	return summary, nil
 }
 
-func (s *composeService) getImages(ctx context.Context, images []string) (map[string]api.ImageSummary, error) {
+func (s *composeService) getImageSummaries(ctx context.Context, repoTags []string) (map[string]api.ImageSummary, error) {
 	summary := map[string]api.ImageSummary{}
 	l := sync.Mutex{}
 	eg, ctx := errgroup.WithContext(ctx)
-	for _, img := range images {
-		img := img
+	for _, repoTag := range repoTags {
+		repoTag := repoTag
 		eg.Go(func() error {
-			inspect, _, err := s.apiClient().ImageInspectWithRaw(ctx, img)
+			inspect, _, err := s.apiClient().ImageInspectWithRaw(ctx, repoTag)
 			if err != nil {
 				if errdefs.IsNotFound(err) {
 					return nil
 				}
-				return fmt.Errorf("unable to get image '%s': %w", img, err)
+				return fmt.Errorf("unable to get image '%s': %w", repoTag, err)
 			}
 			tag := ""
 			repository := ""
-			if len(inspect.RepoTags) > 0 {
-				ref, err := reference.ParseDockerRef(inspect.RepoTags[0])
-				if err != nil {
-					return err
-				}
-				repository = reference.FamiliarName(ref)
-				if tagged, ok := ref.(reference.Tagged); ok {
-					tag = tagged.Tag()
-				}
+			ref, err := reference.ParseDockerRef(repoTag)
+			if err != nil {
+				return err
+			}
+			repository = reference.FamiliarName(ref)
+			if tagged, ok := ref.(reference.Tagged); ok {
+				tag = tagged.Tag()
 			}
 			l.Lock()
-			summary[img] = api.ImageSummary{
+			summary[repoTag] = api.ImageSummary{
 				ID:         inspect.ID,
 				Repository: repository,
 				Tag:        tag,
diff --git a/pkg/compose/start.go b/pkg/compose/start.go
index e4ce16fd4..f5dbd9101 100644
--- a/pkg/compose/start.go
+++ b/pkg/compose/start.go
@@ -214,13 +214,13 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 	}
 
 	var (
-		expected []string
+		expected = utils.NewSet[string]()
 		watched  = map[string]int{}
 		replaced []string
 	)
 	for _, c := range containers {
 		if isRequired(c) {
-			expected = append(expected, c.ID)
+			expected.Add(c.ID)
 		}
 		watched[c.ID] = 0
 	}
@@ -242,7 +242,7 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 					// be able to inspect in time before they're gone from the
 					// API, so just remove the watch without erroring
 					delete(watched, event.Container)
-					expected = utils.Remove(expected, event.Container)
+					expected.Remove(event.Container)
 					return nil
 				}
 				return err
@@ -253,7 +253,6 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 				Labels: inspected.Config.Labels,
 			}
 			name := getContainerNameWithoutProject(container)
-
 			service := container.Labels[api.ServiceLabel]
 			switch event.Status {
 			case "stop":
@@ -278,7 +277,7 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 				}
 
 				delete(watched, container.ID)
-				expected = utils.Remove(expected, container.ID)
+				expected.Remove(container.ID)
 			case "die":
 				restarted := watched[container.ID]
 				watched[container.ID] = restarted + 1
@@ -308,7 +307,7 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 				if !willRestart {
 					// we're done with this one
 					delete(watched, container.ID)
-					expected = utils.Remove(expected, container.ID)
+					expected.Remove(container.ID)
 				}
 			case "start":
 				count, ok := watched[container.ID]
@@ -316,7 +315,7 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 				if !ok {
 					// A new container has just been added to service by scale
 					watched[container.ID] = 0
-					expected = append(expected, container.ID)
+					expected.Add(container.ID)
 					mustAttach = true
 				}
 				if mustAttach {
@@ -333,17 +332,15 @@ func (s *composeService) watchContainers(ctx context.Context, //nolint:gocyclo
 					if err != nil {
 						return err
 					}
-					if utils.StringContains(expected, id) {
-						expected = append(expected, inspected.ID)
+					if expected.Has(id) {
+						expected.Add(inspected.ID)
+						expected.Add(container.ID)
 					}
 					watched[container.ID] = 1
-					if utils.Contains(expected, id) {
-						expected = append(expected, container.ID)
-					}
 				} else if ofInterest(container) {
 					watched[container.ID] = 1
 					if isRequired(container) {
-						expected = append(expected, container.ID)
+						expected.Add(container.ID)
 					}
 				}
 			}
diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go
index 15d2c44c4..f469820b1 100644
--- a/pkg/compose/watch.go
+++ b/pkg/compose/watch.go
@@ -512,9 +512,15 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr
 				return err
 			}
 
+			services := []string{serviceName}
+			p, err := project.WithSelectedServices(services)
+			if err != nil {
+				return err
+			}
 			err = s.start(ctx, project.Name, api.StartOptions{
-				Project:  project,
-				Services: []string{serviceName},
+				Project:  p,
+				Services: services,
+				AttachTo: services,
 			}, nil)
 			if err != nil {
 				options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Application failed to start after update. Error: %v", err))
diff --git a/pkg/progress/event.go b/pkg/progress/event.go
index 267dbe192..7b66946ee 100644
--- a/pkg/progress/event.go
+++ b/pkg/progress/event.go
@@ -153,6 +153,16 @@ func RemovedEvent(id string) Event {
 	return NewEvent(id, Done, "Removed")
 }
 
+// BuildingEvent creates a new Building in progress Event
+func BuildingEvent(id string) Event {
+	return NewEvent(id, Working, "Building")
+}
+
+// BuiltEvent creates a new built (done) Event
+func BuiltEvent(id string) Event {
+	return NewEvent(id, Done, "Built")
+}
+
 // SkippedEvent creates a new Skipped Event
 func SkippedEvent(id string, reason string) Event {
 	return Event{


Test patch:
diff --git a/pkg/compose/images_test.go b/pkg/compose/images_test.go
new file mode 100644
index 000000000..9da4c6815
--- /dev/null
+++ b/pkg/compose/images_test.go
@@ -0,0 +1,103 @@
+/*
+   Copyright 2024 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 (
+	"context"
+	"strings"
+	"testing"
+
+	containerType "github.com/docker/docker/api/types/container"
+	"go.uber.org/mock/gomock"
+	"gotest.tools/v3/assert"
+
+	compose "github.com/docker/compose/v2/pkg/api"
+	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+)
+
+func TestImages(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+
+	api, cli := prepareMocks(mockCtrl)
+	tested := composeService{
+		dockerCli: cli,
+	}
+
+	ctx := context.Background()
+	args := filters.NewArgs(projectFilter(strings.ToLower(testProject)))
+	listOpts := containerType.ListOptions{All: true, Filters: args}
+	image1 := imageInspect("image1", "foo:1", 12345)
+	image2 := imageInspect("image2", "bar:2", 67890)
+	api.EXPECT().ImageInspectWithRaw(anyCancellableContext(), "foo:1").Return(image1, nil, nil)
+	api.EXPECT().ImageInspectWithRaw(anyCancellableContext(), "bar:2").Return(image2, nil, nil)
+	c1 := containerDetail("service1", "123", "running", "foo:1")
+	c2 := containerDetail("service1", "456", "running", "bar:2")
+	c2.Ports = []moby.Port{{PublicPort: 80, PrivatePort: 90, IP: "localhost"}}
+	c3 := containerDetail("service2", "789", "exited", "foo:1")
+	api.EXPECT().ContainerList(ctx, listOpts).Return([]moby.Container{c1, c2, c3}, nil)
+
+	images, err := tested.Images(ctx, strings.ToLower(testProject), compose.ImagesOptions{})
+
+	expected := []compose.ImageSummary{
+		{
+			ID:            "image1",
+			ContainerName: "123",
+			Repository:    "foo",
+			Tag:           "1",
+			Size:          12345,
+		},
+		{
+			ID:            "image2",
+			ContainerName: "456",
+			Repository:    "bar",
+			Tag:           "2",
+			Size:          67890,
+		},
+		{
+			ID:            "image1",
+			ContainerName: "789",
+			Repository:    "foo",
+			Tag:           "1",
+			Size:          12345,
+		},
+	}
+	assert.NilError(t, err)
+	assert.DeepEqual(t, images, expected)
+}
+
+func imageInspect(id string, image string, size int64) moby.ImageInspect {
+	return moby.ImageInspect{
+		ID: id,
+		RepoTags: []string{
+			"someRepo:someTag",
+			image,
+		},
+		Size: size,
+	}
+}
+
+func containerDetail(service string, id string, status string, image string) moby.Container {
+	return moby.Container{
+		ID:     id,
+		Names:  []string{"/" + id},
+		Image:  image,
+		Labels: containerLabels(service, false),
+		State:  status,
+	}
+}
diff --git a/pkg/e2e/fixtures/profiles/docker-compose.yaml b/pkg/e2e/fixtures/profiles/docker-compose.yaml
new file mode 100644
index 000000000..134d7bbc9
--- /dev/null
+++ b/pkg/e2e/fixtures/profiles/docker-compose.yaml
@@ -0,0 +1,15 @@
+services:
+  foo:
+    container_name: foo_c
+    profiles: [ test ]
+    image: alpine
+    depends_on: [ db ]
+
+  bar:
+    container_name: bar_c
+    profiles: [ test ]
+    image: alpine
+  
+  db:
+    container_name: db_c
+    image: alpine
\ No newline at end of file
diff --git a/pkg/e2e/fixtures/scale/Dockerfile b/pkg/e2e/fixtures/scale/Dockerfile
new file mode 100644
index 000000000..7f341f952
--- /dev/null
+++ b/pkg/e2e/fixtures/scale/Dockerfile
@@ -0,0 +1,17 @@
+#   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.
+
+FROM nginx:alpine
+ARG FOO
+LABEL FOO=$FOO
\ No newline at end of file
diff --git a/pkg/e2e/fixtures/scale/build.yaml b/pkg/e2e/fixtures/scale/build.yaml
new file mode 100644
index 000000000..cd109c7a8
--- /dev/null
+++ b/pkg/e2e/fixtures/scale/build.yaml
@@ -0,0 +1,3 @@
+services:
+  test:
+    build: .
diff --git a/pkg/e2e/scale_test.go b/pkg/e2e/scale_test.go
index 21595dd47..7a859187b 100644
--- a/pkg/e2e/scale_test.go
+++ b/pkg/e2e/scale_test.go
@@ -184,3 +184,34 @@ func checkServiceContainer(t *testing.T, stdout, containerName, containerState s
 	}
 	testify.Fail(t, errMessage, stdout)
 }
+
+func TestScaleDownNoRecreate(t *testing.T) {
+	const projectName = "scale-down-recreated-test"
+	c := NewCLI(t, WithEnv(
+		"COMPOSE_PROJECT_NAME="+projectName))
+
+	reset := func() {
+		c.RunDockerComposeCmd(t, "down", "--rmi", "all")
+	}
+	t.Cleanup(reset)
+	c.RunDockerComposeCmd(t, "-f", "fixtures/scale/build.yaml", "build", "--build-arg", "FOO=test")
+	c.RunDockerComposeCmd(t, "-f", "fixtures/scale/build.yaml", "up", "-d", "--scale", "test=2")
+
+	c.RunDockerComposeCmd(t, "-f", "fixtures/scale/build.yaml", "build", "--build-arg", "FOO=updated")
+	c.RunDockerComposeCmd(t, "-f", "fixtures/scale/build.yaml", "up", "-d", "--scale", "test=4", "--no-recreate")
+
+	res := c.RunDockerComposeCmd(t, "ps", "--format", "{{.Name}}", "test")
+	res.Assert(t, icmd.Success)
+	assert.Check(t, strings.Contains(res.Stdout(), "scale-down-recreated-test-test-1"))
+	assert.Check(t, strings.Contains(res.Stdout(), "scale-down-recreated-test-test-2"))
+	assert.Check(t, strings.Contains(res.Stdout(), "scale-down-recreated-test-test-3"))
+	assert.Check(t, strings.Contains(res.Stdout(), "scale-down-recreated-test-test-4"))
+
+	t.Log("scale down removes obsolete replica #1 and #2")
+	c.NewDockerComposeCmd(t, "--project-directory", "fixtures/scale", "up", "-d", "--scale", "test=2")
+
+	res = c.RunDockerComposeCmd(t, "ps", "--format", "{{.Name}}", "test")
+	res.Assert(t, icmd.Success)
+	assert.Check(t, strings.Contains(res.Stdout(), "scale-down-recreated-test-test-3"))
+	assert.Check(t, strings.Contains(res.Stdout(), "scale-down-recreated-test-test-4"))
+}
diff --git a/pkg/e2e/up_test.go b/pkg/e2e/up_test.go
index e6e138c3b..83ce235a1 100644
--- a/pkg/e2e/up_test.go
+++ b/pkg/e2e/up_test.go
@@ -179,3 +179,16 @@ func TestUpWithAllResources(t *testing.T) {
 	assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Volume "%s_my_vol"  Created`, projectName)), res.Combined())
 	assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Network %s_my_net  Created`, projectName)), res.Combined())
 }
+
+func TestUpProfile(t *testing.T) {
+	c := NewCLI(t)
+	const projectName = "compose-e2e-up-profile"
+	t.Cleanup(func() {
+		c.RunDockerComposeCmd(t, "--project-name", projectName, "--profile", "test", "down", "-v")
+	})
+
+	res := c.RunDockerComposeCmd(t, "-f", "./fixtures/profiles/docker-compose.yaml", "--project-name", projectName, "up", "foo")
+	assert.Assert(t, strings.Contains(res.Combined(), `Container db_c  Created`), res.Combined())
+	assert.Assert(t, strings.Contains(res.Combined(), `Container foo_c  Created`), res.Combined())
+	assert.Assert(t, !strings.Contains(res.Combined(), `Container bar_c  Created`), res.Combined())
+}