Repository: argoproj/argo-rollouts

Issue:
`replicaProgressThreshold` does not fully promote canary pods
<!-- If you are trying to resolve an environment-specific issue or have a one-off question about the edge case that does not require a feature then please consider asking a question in argo rollouts slack [channel](https://argoproj.github.io/community/join-slack). -->

Checklist:

* [x] I've included steps to reproduce the bug.
* [x] I've included the version of argo rollouts.

**Describe the bug**
When using `replicaProgressThreshold` introduced in https://github.com/argoproj/argo-rollouts/pull/4341, the intermediate `setWeight` steps will as expected allow the Rollout to progress when there is less than 100% ready pods, as long as the threshold is met. 

However, the final promotion step [here](https://github.com/argoproj/argo-rollouts/blob/0c6ebf750a2c5fa9da1b4eddc75972db0f0f55d3/rollout/sync.go#L947-L949) and the step scales down the old stable replicaSet [here](https://github.com/argoproj/argo-rollouts/blob/0c6ebf750a2c5fa9da1b4eddc75972db0f0f55d3/rollout/trafficrouting.go#L268-L270) waits for 100% readiness before proceeding, so old stable RS pods will stick around forever, even if all canary steps have successfully completed. I would expect that the Rollout fully progresses and is completed as long as the threshold is met.

I found a similar unmerged PR https://github.com/argoproj/argo-rollouts/pull/4239 that addresses these final promotion issues, but introduces a different API from `replicaProgressThreshold`. 

Internally, we have a fork that addresses these promotion issues but uses the existing `replicaProgressThreshold` API. I can create a PR to upstream these changes if these changes make sense.

**To Reproduce**
Create a Rollout that includes a flakey container

```yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: replica-progress-threshold-test
  namespace: replica-progress-threshold-test
spec:
  replicas: 20
  revisionHistoryLimit: 1
  selector:
    matchLabels:
      app: replica-progress-threshold-test
  strategy:
    canary:
      replicaProgressThreshold:
        type: Percent
        value: 90
      steps:
      - setWeight: 100
      - pause:
          duration: 600s
      trafficRouting: {}
  template:
    metadata:
      labels:
        app: replica-progress-threshold-test
    spec:
      containers:
      - command:
        - /bin/bash
        - -c
        - |
          num=$(shuf -i 1-20 -n 1)
          if [[ $num -eq 1 ]]; then
              echo "Flaking out!"
          else
              echo "Marking container as ready!"
              touch /tmp/ready
          fi

          sleep infinity
        image: ubuntu:24.04
        name: flakey-container
        readinessProbe:
          exec:
            command:
            - sh
            - -c
            - test -f /tmp/ready
          failureThreshold: 1
          initialDelaySeconds: 5
          periodSeconds: 5
          timeoutSeconds: 100
status: {}
```

**Expected behavior**

Since we set `replicaProgressThreshold` to 90%, if only 1 or 2 pods remain unhealthy, the canary replicaSet should automatically be promoted to stable and the old stable replicaSet should be scaled down to 0.

**Version**
I'm using head of `master` since https://github.com/argoproj/argo-rollouts/pull/4341 hasn't been released yet.

---
<!-- Issue Author: Don't delete this message to encourage other users to support your issue! -->
**Message from the maintainers**:

Impacted by this bug? Give it a 👍. We prioritize the issues with the most 👍.


Discussion:
@schahal: > Internally, we have a fork that addresses these promotion issues but uses the existing `replicaProgressThreshold` API. I can create a PR to upstream these changes if these changes make sense.

@danp60 I think the changes you described makes sense! An upstream PR probably is the best bet so when v1.9 is cut it's included in there.

Solution patch:
diff --git a/.github/workflows/image-reuse.yaml b/.github/workflows/image-reuse.yaml
index 1f8dff82..0b6f1224 100644
--- a/.github/workflows/image-reuse.yaml
+++ b/.github/workflows/image-reuse.yaml
@@ -106,7 +106,7 @@ jobs:
             echo 'EOF' >> $GITHUB_ENV
 
       - name: Login to Quay.io
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
         with:
           registry: quay.io
           username: ${{ secrets.quay_username }}
@@ -114,7 +114,7 @@ jobs:
         if: ${{ inputs.quay_image_name && inputs.push }}
 
       - name: Login to GitHub Container Registry
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
         with:
           registry: ghcr.io
           username: ${{ secrets.ghcr_username }}
@@ -122,7 +122,7 @@ jobs:
         if: ${{ inputs.ghcr_image_name && inputs.push }}
 
       - name: Login to dockerhub Container Registry
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
+        uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
         with:
           username: ${{ secrets.docker_username }}
           password: ${{ secrets.docker_password }}
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 4ec56c6c..98a29565 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -107,7 +107,7 @@ jobs:
           make manifests IMAGE_TAG=${{ github.ref_name }}
 
       - name: Draft release
-        uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v0.1.15
+        uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v0.1.15
         with:
           tag_name: ${{ github.event.inputs.tag }}
           draft: true
@@ -212,7 +212,7 @@ jobs:
             /tmp/sbom.tar.gz
 
       - name: Upload SBOM and signature assets
-        uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v0.1.15
+        uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v0.1.15
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74d34db8..9ba2f44d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -839,9 +839,9 @@
 
 ### Fix
 
-* make new alb fullName field  optional for backward compatability ([#2806](https://github.com/argoproj/argo-rollouts/issues/2806))
+* make new alb fullName field  optional for backward compatibility ([#2806](https://github.com/argoproj/argo-rollouts/issues/2806))
 * cloudwatch metrics provider multiple dimensions ([#2932](https://github.com/argoproj/argo-rollouts/issues/2932))
-*  rollout not modify the VirtualService whit setHeaderRoute step with workloadRef ([#2797](https://github.com/argoproj/argo-rollouts/issues/2797))
+*  rollout not modify the VirtualService with setHeaderRoute step with workloadRef ([#2797](https://github.com/argoproj/argo-rollouts/issues/2797))
 * get new httpRoutesI after removeRoute() to avoid duplicates. Fixes [#2769](https://github.com/argoproj/argo-rollouts/issues/2769) ([#2887](https://github.com/argoproj/argo-rollouts/issues/2887))
 * change logic of analysis run to better handle errors ([#2695](https://github.com/argoproj/argo-rollouts/issues/2695))
 * istio dropping fields during removing of managed routes ([#2692](https://github.com/argoproj/argo-rollouts/issues/2692))
@@ -883,7 +883,7 @@ types.
 
 ### Fix
 
-* make new alb fullName field  optional for backward compatability ([#2806](https://github.com/argoproj/argo-rollouts/issues/2806))
+* make new alb fullName field  optional for backward compatibility ([#2806](https://github.com/argoproj/argo-rollouts/issues/2806))
 * properly wrap Datadog API v2 request body ([#2771](https://github.com/argoproj/argo-rollouts/issues/2771)) ([#2775](https://github.com/argoproj/argo-rollouts/issues/2775))
 
 
@@ -1083,7 +1083,7 @@ There was an unintentional change in behavior related to service creation with e
 
 ### Build
 
-* use fixed docker repository because we can't reach accross jobs ([#2474](https://github.com/argoproj/argo-rollouts/issues/2474))
+* use fixed docker repository because we can't reach across jobs ([#2474](https://github.com/argoproj/argo-rollouts/issues/2474))
 * copy proto files from GOPATH so we can clone outside of GOPATH ([#2360](https://github.com/argoproj/argo-rollouts/issues/2360))
 * add sha256 checksums for all released bins ([#2332](https://github.com/argoproj/argo-rollouts/issues/2332))
 
diff --git a/README.md b/README.md
index dc6c87d0..9f02553d 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Argo Rollouts is a Kubernetes controller and set of CRDs which provide advanced
 
 Argo Rollouts (optionally) integrates with ingress controllers and service meshes, leveraging their traffic shaping abilities to gradually shift traffic to the new version during an update. Additionally, Rollouts can query and interpret metrics from various providers to verify key KPIs and drive automated promotion or rollback during an update.
 
-[![Argo Rollotus Demo](https://img.youtube.com/vi/hIL0E2gLkf8/0.jpg)](https://youtu.be/hIL0E2gLkf8)
+[![Argo Rollouts Demo](https://img.youtube.com/vi/hIL0E2gLkf8/0.jpg)](https://youtu.be/hIL0E2gLkf8)
 
 ## Quick Start
 
diff --git a/docs/best-practices.md b/docs/best-practices.md
index 18f62168..6d1fd51f 100644
--- a/docs/best-practices.md
+++ b/docs/best-practices.md
@@ -176,7 +176,7 @@ Each time a change occurs in the ConfigMap, its name will change in the Deployme
 
 However, it's not enough to perform correctly progressive rollouts, as the old ConfigMap might get deleted as soon as the new one is created. This would prevent Experiments and rollbacks in case of rollout failure to work correctly.
 
-While no magical solution exist to work aroud that, you can tweak your deployment tool to remove the ConfigMap only when the Rollout is completed successfully.
+While no magical solution exists to work around that, you can tweak your deployment tool to remove the ConfigMap only when the Rollout is completed successfully.
 
 Example with Argo CD:
 
diff --git a/docs/concepts.md b/docs/concepts.md
index 433cbd53..1c5ed255 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -33,7 +33,7 @@ While the industry has used a consistent terminology to describe various deploym
 A `RollingUpdate` slowly replaces the old version with the new version. As the new version comes up, the old version is scaled down in order to maintain the overall count of the application. This is the default strategy of the Deployment object.
 
 ### Recreate
-A Recreate deployment deletes the old version of the application before bring up the new version. As a result, this ensures that two versions of the application never run at the same time, but there is downtime during the deployment.
+A Recreate deployment deletes the old version of the application before bringing up the new version. As a result, this ensures that two versions of the application never run at the same time, but there is downtime during the deployment.
 
 ### Blue-Green
 A Blue-Green deployment (sometimes referred to as a Red-Black) has both the new and old version of the application deployed at the same time.  During this time, only the old version of the application will receive production traffic. This allows the developers to run tests against the new version before switching the live traffic to the new version.
diff --git a/docs/features/canary/index.md b/docs/features/canary/index.md
index bd196985..76d529d8 100644
--- a/docs/features/canary/index.md
+++ b/docs/features/canary/index.md
@@ -132,7 +132,7 @@ spec:
         - pause: {}
 ```
 
-The above situation is caused by the changed behvaior of `setWeight` after `setCanaryScale`. To reset, set `matchTrafficWeight: true` and the `setWeight` behavior will be restored, i.e., subsequent `setWeight` will create canary replicas matching the traffic weight.
+The above situation is caused by the changed behavior of `setWeight` after `setCanaryScale`. To reset, set `matchTrafficWeight: true` and the `setWeight` behavior will be restored, i.e., subsequent `setWeight` will create canary replicas matching the traffic weight.
 
 ## Dynamic Stable Scale (with Traffic Routing)
 
diff --git a/docs/features/kubectl-plugin.md b/docs/features/kubectl-plugin.md
index e6cc98f5..8242913d 100644
--- a/docs/features/kubectl-plugin.md
+++ b/docs/features/kubectl-plugin.md
@@ -8,7 +8,7 @@ Argo Rollouts offers a Kubectl plugin to enrich the experience with Rollouts, Ex
 See the [installation guide](../installation.md) for instructions on installing the plugin.
 
 ## Usage
-The best way to get information on the available Argo Rollouts kubectl plugin commands is by run `kubectl argo rollouts`. The plugin lists all the available commands that the tool can execute along with a description of each commend. All the plugin's commands interact with the Kubernetes API server and use KubeConfig credentials for authentication. Since the plugin leverages the KubeConfig of the user running the command, the plugin has the permissions of those configs. 
+The best way to get information on the available Argo Rollouts kubectl plugin commands is by running `kubectl argo rollouts`. The plugin lists all the available commands that the tool can execute along with a description of each command. All the plugin's commands interact with the Kubernetes API server and use KubeConfig credentials for authentication. Since the plugin leverages the KubeConfig of the user running the command, the plugin has the permissions of those configs. 
 
 Similar to kubectl, the plugin uses many of the same flags as the kubectl. For example, the `kubectl argo rollouts get rollout canary-demo -w` command starts a watch on the `canary-demo` rollout object similar to how the `kubectl get deployment canary-demo -w` command starts a watch on a deployment.
 
diff --git a/docs/features/traffic-management/alb.md b/docs/features/traffic-management/alb.md
index 21d1f5f4..64dbd386 100644
--- a/docs/features/traffic-management/alb.md
+++ b/docs/features/traffic-management/alb.md
@@ -51,7 +51,7 @@ spec:
           # the AWS Load Balancer Controller to split traffic between the canary and stable
           # Service, according to the desired traffic weight (required).
           ingress: ingress
-          # If you want to controll multiple ingress resources you can use the ingresses field, if ingresses is specified
+          # If you want to control multiple ingress resources you can use the ingresses field, if ingresses is specified
           # the ingress field will need to be omitted.
           ingresses:
            - ingress-1
diff --git a/docs/proposals/step-plugin.md b/docs/proposals/step-plugin.md
index 20bd227d..2528ea8f 100644
--- a/docs/proposals/step-plugin.md
+++ b/docs/proposals/step-plugin.md
@@ -293,7 +293,7 @@ A utility function such as `PluginHelper.GetStatuses(rollout, pluginName)` can b
 A parameter such as `abortOnFailure` can be added to the Rollout plugin step configuration object.
 When specified, the controller can use the value to modify the default logic.
 
-##### I dont want my plugin execution time to count towards the progress deadline
+##### I don't want my plugin execution time to count towards the progress deadline
 
 A parameter such as `ignoreProgressDeadline` can be added to the Rollout plugin step configuration object.
 When specified, the controller can use the value to modify the default logic.
diff --git a/rollout/replicaset.go b/rollout/replicaset.go
index 5f039640..3a641ed4 100644
--- a/rollout/replicaset.go
+++ b/rollout/replicaset.go
@@ -176,11 +176,29 @@ func (c *rolloutContext) reconcileNewReplicaSet() (bool, error) {
 	revision, _ := replicasetutil.Revision(c.newRS)
 
 	if revision == 1 && c.rollout.Spec.WorkloadRef != nil && c.rollout.Spec.WorkloadRef.ScaleDown == v1alpha1.ScaleDownProgressively {
-		oldScale := defaults.GetReplicasOrDefault(c.newRS.Spec.Replicas)
-		// scale down the deployment when the rollout has ready replicas or scale up the deployment if rollout fails
-		if c.rollout.Spec.Replicas != nil && (c.rollout.Status.ReadyReplicas > 0 || oldScale > newReplicasCount) {
-			targetScale := *c.rollout.Spec.Replicas - c.rollout.Status.ReadyReplicas
+		// NOTE: Progressive deployment scaling is designed for one-time migration from Deployment to Rollout.
+		// It only triggers on revision == 1, which has a limitation: if the initial rollout (revision 1) fails
+		// and users fix the issue causing a new revision (revision 2+), the deployment will never be scaled down.
+		// This matches the behavior of scaleDown: onSuccess and may need to be addressed in a future enhancement.
+		//
+		// When healthy: Scale deployment to 0 to prevent external controllers (HPA/KEDA) from interfering
+		// When not healthy: Scale deployment based on rollout's ready replicas to maintain availability
+		if c.rollout.Status.Phase == v1alpha1.RolloutPhaseHealthy {
+			var targetScale int32 = 0
 			err = c.scaleDeployment(&targetScale)
+			if err != nil {
+				c.log.Errorf("Failed to scale deployment to 0 during progressive migration: %v", err)
+			}
+		} else {
+			// scale down the deployment when the rollout has ready replicas or scale up the deployment if rollout fails
+			oldScale := defaults.GetReplicasOrDefault(c.newRS.Spec.Replicas)
+			if c.rollout.Spec.Replicas != nil && (c.rollout.Status.ReadyReplicas > 0 || oldScale > newReplicasCount) {
+				targetScale := *c.rollout.Spec.Replicas - c.rollout.Status.ReadyReplicas
+				err = c.scaleDeployment(&targetScale)
+				if err != nil {
+					c.log.Errorf("Failed to scale deployment during progressive migration: %v", err)
+				}
+			}
 		}
 	}
 
diff --git a/rollout/sync.go b/rollout/sync.go
index 0c8e2e2f..d630cb1a 100644
--- a/rollout/sync.go
+++ b/rollout/sync.go
@@ -944,7 +944,10 @@ func (c *rolloutContext) shouldFullPromote(newStatus v1alpha1.RolloutStatus) str
 		if c.pauseContext.IsAborted() {
 			return ""
 		}
-		if c.newRS == nil || c.newRS.Status.AvailableReplicas != defaults.GetReplicasOrDefault(c.rollout.Spec.Replicas) {
+		if c.newRS == nil ||
+			(c.newRS.Status.AvailableReplicas != defaults.GetReplicasOrDefault(c.rollout.Spec.Replicas) &&
+				// If the replica progress threshold is met, we can fully promote canary to stable.
+				!replicasetutil.ReplicaProgressThresholdMet(c.rollout.Spec.Strategy.Canary.ReplicaProgressThreshold, c.newRS, defaults.GetReplicasOrDefault(c.rollout.Spec.Replicas))) {
 			return ""
 		}
 		if c.rollout.Status.PromoteFull {
diff --git a/rollout/trafficrouting.go b/rollout/trafficrouting.go
index 5808cd3e..18b03e31 100644
--- a/rollout/trafficrouting.go
+++ b/rollout/trafficrouting.go
@@ -137,6 +137,8 @@ func (c *Controller) NewTrafficRoutingReconciler(roCtx *rolloutContext) ([]traff
 	return nil, nil
 }
 
+// checkReplicasAvailable checks if the given replicaset has enough available replicas
+// for the desiredWeight taking into consideration replicaProgressThreshold.
 func (c *rolloutContext) checkReplicasAvailable(rs *appsv1.ReplicaSet, desiredWeight int32) bool {
 	if rs == nil {
 		return false
@@ -145,7 +147,8 @@ func (c *rolloutContext) checkReplicasAvailable(rs *appsv1.ReplicaSet, desiredWe
 	totalReplicas := *c.rollout.Spec.Replicas
 
 	desiredReplicas := (desiredWeight * totalReplicas) / 100
-	if availableReplicas < desiredReplicas {
+	if availableReplicas < desiredReplicas &&
+		!replicasetutil.ReplicaProgressThresholdMet(c.rollout.Spec.Strategy.Canary.ReplicaProgressThreshold, rs, desiredReplicas) {
 		c.log.Infof("ReplicaSet '%s' has %d available replicas, waiting for %d", rs.Name, availableReplicas, desiredReplicas)
 		return false
 	}
@@ -265,6 +268,8 @@ func (c *rolloutContext) reconcileTrafficRouting() error {
 			}
 		}
 
+		// check if the stable RS has enough pods before recalculating the new
+		// weight status.
 		if !c.checkReplicasAvailable(c.stableRS, weightutil.MaxTrafficWeight(c.rollout)-desiredWeight) {
 			return nil
 		}
diff --git a/utils/replicaset/canary.go b/utils/replicaset/canary.go
index 9242b222..8e89e5d5 100644
--- a/utils/replicaset/canary.go
+++ b/utils/replicaset/canary.go
@@ -21,7 +21,7 @@ const (
 )
 
 func allDesiredAreAvailable(rs *appsv1.ReplicaSet, desired int32) bool {
-	return rs != nil && desired == *rs.Spec.Replicas && desired == rs.Status.AvailableReplicas
+	return rs != nil && desired == defaults.GetReplicasOrDefault(rs.Spec.Replicas) && desired == rs.Status.AvailableReplicas
 }
 
 func allDesiredAreCreated(rs *appsv1.ReplicaSet, desired int32) bool {



Test patch:
diff --git a/rollout/replicaset_test.go b/rollout/replicaset_test.go
index 56ca00b1..37f2e059 100644
--- a/rollout/replicaset_test.go
+++ b/rollout/replicaset_test.go
@@ -592,6 +592,7 @@ func TestScaleDownProgressively(t *testing.T) {
 		newRSRevision              string
 		rolloutReplicas            int32
 		rolloutReadyReplicas       int32
+		rolloutPhase               v1alpha1.RolloutPhase
 		abortScaleDownDelaySeconds int32
 		expectedDeploymentReplicas int32
 	}{
@@ -602,6 +603,7 @@ func TestScaleDownProgressively(t *testing.T) {
 			newRSRevision:              "1",
 			rolloutReplicas:            5,
 			rolloutReadyReplicas:       3,
+			rolloutPhase:               v1alpha1.RolloutPhaseProgressing,
 			abortScaleDownDelaySeconds: 0,
 			expectedDeploymentReplicas: 2,
 		},
@@ -612,6 +614,7 @@ func TestScaleDownProgressively(t *testing.T) {
 			newRSRevision:              "1",
 			rolloutReplicas:            5,
 			rolloutReadyReplicas:       1,
+			rolloutPhase:               v1alpha1.RolloutPhaseProgressing,
 			abortScaleDownDelaySeconds: 0,
 			expectedDeploymentReplicas: 4,
 		},
@@ -622,30 +625,44 @@ func TestScaleDownProgressively(t *testing.T) {
 			newRSRevision:              "2",
 			rolloutReplicas:            5,
 			rolloutReadyReplicas:       3,
+			rolloutPhase:               v1alpha1.RolloutPhaseHealthy,
 			abortScaleDownDelaySeconds: 0,
 			expectedDeploymentReplicas: 5,
 		},
+		{
+			name:                       "Rollout healthy - Deployment scaled to 0",
+			deploymentReplicas:         2,
+			newRSReplicas:              5,
+			newRSRevision:              "1",
+			rolloutReplicas:            5,
+			rolloutReadyReplicas:       5,
+			rolloutPhase:               v1alpha1.RolloutPhaseHealthy,
+			abortScaleDownDelaySeconds: 0,
+			expectedDeploymentReplicas: 0,
+		},
 	}
 
 	for _, test := range tests {
-		ctx := createScaleDownRolloutContext(v1alpha1.ScaleDownProgressively, test.deploymentReplicas, true, nil)
-		ctx.rollout.Spec.Strategy = v1alpha1.RolloutStrategy{
-			BlueGreen: &v1alpha1.BlueGreenStrategy{
-				AbortScaleDownDelaySeconds: &test.abortScaleDownDelaySeconds,
-			},
-		}
-		ctx.newRS = rs("foo-v2", test.newRSReplicas, nil, noTimestamp, nil)
-		ctx.newRS.ObjectMeta.Annotations[annotations.RevisionAnnotation] = test.newRSRevision
-		ctx.pauseContext.removeAbort = true
-		ctx.rollout.Spec.Replicas = &test.rolloutReplicas
-		ctx.rollout.Status.ReadyReplicas = test.rolloutReadyReplicas
-
-		_, err := ctx.reconcileNewReplicaSet()
-		assert.Nil(t, err)
-		k8sfakeClient := ctx.kubeclientset.(*k8sfake.Clientset)
-		updatedDeployment, err := k8sfakeClient.AppsV1().Deployments("default").Get(context.TODO(), "workload-test", metav1.GetOptions{})
-		assert.Nil(t, err)
-		assert.Equal(t, test.expectedDeploymentReplicas, *updatedDeployment.Spec.Replicas)
-
+		t.Run(test.name, func(t *testing.T) {
+			ctx := createScaleDownRolloutContext(v1alpha1.ScaleDownProgressively, test.deploymentReplicas, true, nil)
+			ctx.rollout.Spec.Strategy = v1alpha1.RolloutStrategy{
+				BlueGreen: &v1alpha1.BlueGreenStrategy{
+					AbortScaleDownDelaySeconds: &test.abortScaleDownDelaySeconds,
+				},
+			}
+			ctx.newRS = rs("foo-v2", test.newRSReplicas, nil, noTimestamp, nil)
+			ctx.newRS.ObjectMeta.Annotations[annotations.RevisionAnnotation] = test.newRSRevision
+			ctx.pauseContext.removeAbort = true
+			ctx.rollout.Spec.Replicas = &test.rolloutReplicas
+			ctx.rollout.Status.ReadyReplicas = test.rolloutReadyReplicas
+			ctx.rollout.Status.Phase = test.rolloutPhase // Set the appropriate phase
+
+			_, err := ctx.reconcileNewReplicaSet()
+			assert.Nil(t, err)
+			k8sfakeClient := ctx.kubeclientset.(*k8sfake.Clientset)
+			updatedDeployment, err := k8sfakeClient.AppsV1().Deployments("default").Get(context.TODO(), "workload-test", metav1.GetOptions{})
+			assert.Nil(t, err)
+			assert.Equal(t, test.expectedDeploymentReplicas, *updatedDeployment.Spec.Replicas)
+		})
 	}
 }
diff --git a/rollout/sync_test.go b/rollout/sync_test.go
index 8015e6a2..ca337906 100644
--- a/rollout/sync_test.go
+++ b/rollout/sync_test.go
@@ -616,6 +616,108 @@ func Test_shouldFullPromote(t *testing.T) {
 	assert.Equal(t, result, "Rollback within window")
 }
 
+func TestShouldFullPromoteWithReplicaProgressThreshold(t *testing.T) {
+	tests := []struct {
+		name                     string
+		availableReplicas        int32
+		threshold                *v1alpha1.ReplicaProgressThreshold
+		expectedPromotionMessage string
+		description              string
+	}{
+		{
+			name:              "threshold met - 90% available with 90% threshold",
+			availableReplicas: 9,
+			threshold: &v1alpha1.ReplicaProgressThreshold{
+				Type:  v1alpha1.ProgressTypePercentage,
+				Value: 90,
+			},
+			expectedPromotionMessage: "Completed all 1 canary steps",
+			description:              "Should promote when threshold is met (9/10 = 90%)",
+		},
+		{
+			name:              "threshold not met - 80% available with 90% threshold",
+			availableReplicas: 8,
+			threshold: &v1alpha1.ReplicaProgressThreshold{
+				Type:  v1alpha1.ProgressTypePercentage,
+				Value: 90,
+			},
+			expectedPromotionMessage: "",
+			description:              "Should not promote when threshold is not met (8/10 = 80% < 90%)",
+		},
+		{
+			name:                     "nil threshold - requires 100% availability",
+			availableReplicas:        9,
+			threshold:                nil,
+			expectedPromotionMessage: "",
+			description:              "Should not promote with nil threshold, requiring 100% availability (9/10 = 90%)",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create stable RS and new RS
+			stableRS := &appsv1.ReplicaSet{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "stable",
+				},
+				Spec: appsv1.ReplicaSetSpec{
+					Replicas: int32Ptr(10),
+				},
+				Status: v1.ReplicaSetStatus{
+					AvailableReplicas: int32(10),
+				},
+			}
+
+			newRS := &appsv1.ReplicaSet{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "new",
+				},
+				Spec: appsv1.ReplicaSetSpec{
+					Replicas: int32Ptr(10),
+				},
+				Status: v1.ReplicaSetStatus{
+					AvailableReplicas: tt.availableReplicas,
+				},
+			}
+
+			replicaSets := []*appsv1.ReplicaSet{stableRS, newRS}
+
+			ctx := &rolloutContext{
+				allRSs:   replicaSets,
+				stableRS: stableRS,
+				newRS:    newRS,
+				rollout: &v1alpha1.Rollout{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "foo",
+						Namespace: "default",
+					},
+					Spec: v1alpha1.RolloutSpec{
+						Replicas: int32Ptr(10),
+						Strategy: v1alpha1.RolloutStrategy{
+							Canary: &v1alpha1.CanaryStrategy{
+								Steps: []v1alpha1.CanaryStep{
+									{SetWeight: int32Ptr(100)},
+								},
+								ReplicaProgressThreshold: tt.threshold,
+							},
+						},
+					},
+				},
+			}
+			ctx.pauseContext = &pauseContext{rollout: ctx.rollout}
+			ctx.log = logutil.WithRollout(ctx.rollout)
+
+			// Set to last step to trigger full promotion check
+			ctx.rollout.Status.CurrentStepIndex = int32Ptr(1)
+			newStatus := v1alpha1.RolloutStatus{}
+
+			result := ctx.shouldFullPromote(newStatus)
+
+			assert.Equal(t, tt.expectedPromotionMessage, result, tt.description)
+		})
+	}
+}
+
 func TestScaleDownDeploymentOnSuccess(t *testing.T) {
 	ctx := createScaleDownRolloutContext(v1alpha1.ScaleDownOnSuccess, 5, true, nil)
 	newStatus := &v1alpha1.RolloutStatus{
diff --git a/rollout/trafficrouting_test.go b/rollout/trafficrouting_test.go
index 78c15b62..e985e4c0 100644
--- a/rollout/trafficrouting_test.go
+++ b/rollout/trafficrouting_test.go
@@ -1502,3 +1502,65 @@ func TestCheckReplicaSetAvailable(t *testing.T) {
 	fix.fakeTrafficRouting.On("RemoveManagedRoutes", mock.Anything, mock.Anything, mock.Anything).Return(nil)
 	fix.run(getKey(rollout1, t))
 }
+
+func TestCheckReplicasAvailableWithReplicaProgressThreshold(t *testing.T) {
+	tests := []struct {
+		name              string
+		availableReplicas int
+		threshold         *v1alpha1.ReplicaProgressThreshold
+		expectedResult    bool
+		description       string
+	}{
+		{
+			name:              "threshold met - 80% available with 80% threshold",
+			availableReplicas: 4,
+			threshold: &v1alpha1.ReplicaProgressThreshold{
+				Type:  v1alpha1.ProgressTypePercentage,
+				Value: 80,
+			},
+			expectedResult: true,
+			description:    "Should return true when threshold is met (4/5 = 80% >= 80%)",
+		},
+		{
+			name:              "threshold not met - 60% available with 80% threshold",
+			availableReplicas: 3,
+			threshold: &v1alpha1.ReplicaProgressThreshold{
+				Type:  v1alpha1.ProgressTypePercentage,
+				Value: 80,
+			},
+			expectedResult: false,
+			description:    "Should return false when threshold is not met (3/5 = 60% < 80%)",
+		},
+		{
+			name:              "nil threshold - requires 100% availability",
+			availableReplicas: 4,
+			threshold:         nil,
+			expectedResult:    false,
+			description:       "Should return false with nil threshold, requiring 100% availability (4/5 = 80%)",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := newCanaryRollout("foo", 10, nil, nil, ptr.To[int32](0), intstr.FromInt(1), intstr.FromInt(0))
+			r.Spec.Strategy.Canary.TrafficRouting = &v1alpha1.RolloutTrafficRouting{
+				SMI: &v1alpha1.SMITrafficRouting{},
+			}
+			r.Spec.Strategy.Canary.ReplicaProgressThreshold = tt.threshold
+
+			roCtx := &rolloutContext{
+				rollout: r,
+				log:     logutil.WithRollout(r),
+			}
+
+			// Create a ReplicaSet with specified available replicas out of 5
+			// For 50% weight on 10 total replicas = 5 desired replicas
+			rs := newReplicaSetWithStatus(r, 5, tt.availableReplicas)
+
+			// Check if replicas are available for 50% weight
+			result := roCtx.checkReplicasAvailable(rs, 50)
+
+			assert.Equal(t, tt.expectedResult, result, tt.description)
+		})
+	}
+}
diff --git a/test/e2e/functional_test.go b/test/e2e/functional_test.go
index 8cc95f99..929ee70d 100644
--- a/test/e2e/functional_test.go
+++ b/test/e2e/functional_test.go
@@ -1559,6 +1559,199 @@ spec:
 		ExpectDeploymentReplicasCount("The deployment has been scaled to 0 replicas", "rollout-ref-deployment", 0)
 }
 
+// TestScaleDownProgressivelyCompleted verifies that deployment stays at 0 replicas
+// after Rollout is complete, even during scaling events (like KEDA scaling)
+func (s *FunctionalSuite) TestScaleDownProgressivelyCompleted() {
+	s.Given().
+		RolloutObjects(`
+kind: Service
+apiVersion: v1
+metadata:
+  name: rollout-bluegreen-active
+spec:
+  selector:
+    app: rollout-ref-deployment
+  ports:
+  - protocol: TCP
+    port: 80
+    targetPort: 8080
+---
+apiVersion: argoproj.io/v1alpha1
+kind: Rollout
+metadata:
+  name: rollout-ref-deployment
+spec:
+  replicas: 3
+  workloadRef:
+    apiVersion: apps/v1
+    kind: Deployment
+    name: rollout-ref-deployment
+    scaleDown: progressively
+  strategy:
+    blueGreen:
+      activeService: rollout-bluegreen-active
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: rollout-ref-deployment
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: rollout-ref-deployment
+  template:
+    metadata:
+      labels:
+        app: rollout-ref-deployment
+    spec:
+      containers:
+        - name: rollouts-demo
+          image: argoproj/rollouts-demo:green
+          resources:
+            requests:
+              memory: 16Mi
+              cpu: 1m
+`).
+		When().
+		ApplyManifests().
+		WaitForRolloutStatus("Healthy").
+		Sleep(3*time.Second). // Give time for Deployment to scale down
+		Then().
+		// Verify Rollout has completed - Deployment should be at 0
+		ExpectDeploymentReplicasCount("The Deployment has been scaled to 0 replicas after Rollout", "rollout-ref-deployment", 0).
+		ExpectRollout("Rollout phase is healthy", func(r *v1alpha1.Rollout) bool {
+			return r.Status.Phase == v1alpha1.RolloutPhaseHealthy
+}).
+    When().
+    // Simulate KEDA-like scaling event by increasing Rollout replicas
+    ScaleRolloutWithWorkloadRef(6).
+    WaitForRolloutAvailableReplicas(6).
+		Sleep(3*time.Second). // Give time for any potential Deployment scaling
+		Then().
+    // Verify Deployment STILL stays at 0 replicas (this is the key test for the fix)
+    ExpectDeploymentReplicasCount("Deployment should remain at 0 replicas during scaling events", "rollout-ref-deployment", 0).
+    ExpectReplicaCounts(6, 6, 6, 6, 6). // All replicas should come from Rollout
+    When().
+    // Test scaling down as well
+    ScaleRolloutWithWorkloadRef(2).
+    WaitForRolloutAvailableReplicas(2).
+		Sleep(3*time.Second).
+		Then().
+		// Deployment should still be at 0
+		ExpectDeploymentReplicasCount("Deployment should remain at 0 replicas during scale down", "rollout-ref-deployment", 0).
+		ExpectReplicaCounts(2, 2, 2, 2, 2).
+    When().
+    // Test a blue-green update to ensure Deployment doesn't scale during revision changes
+    UpdateWorkloadRef("rollout-ref-deployment").
+    WaitForRolloutStatus("Progressing").
+		WaitForRolloutStatus("Healthy").
+		Sleep(2*time.Second).
+		Then().
+		// Even after blue-green update, Deployment should remain at 0
+		ExpectDeploymentReplicasCount("Deployment should remain at 0 replicas after blue-green update", "rollout-ref-deployment", 0)
+}
+
+// TestScaleDownProgressivelyCompletedCanary verifies that deployment stays at 0 replicas
+// after Canary Rollout is complete, even during scaling events (like KEDA scaling)
+func (s *FunctionalSuite) TestScaleDownProgressivelyCompletedCanary() {
+	s.Given().
+		RolloutObjects(`
+kind: Service
+apiVersion: v1
+metadata:
+  name: rollout-canary-active
+spec:
+  selector:
+    app: rollout-ref-canary
+  ports:
+  - protocol: TCP
+    port: 80
+    targetPort: 8080
+---
+apiVersion: argoproj.io/v1alpha1
+kind: Rollout
+metadata:
+  name: rollout-ref-canary
+spec:
+  replicas: 4
+  workloadRef:
+    apiVersion: apps/v1
+    kind: Deployment
+    name: rollout-ref-canary
+    scaleDown: progressively
+  strategy:
+    canary:
+      maxSurge: 1
+      maxUnavailable: 0
+      steps:
+      - setWeight: 25
+      - pause: {duration: 2s}
+      - setWeight: 50
+      - pause: {duration: 2s}
+      - setWeight: 75
+      - pause: {duration: 2s}
+  selector:
+    matchLabels:
+      app: rollout-ref-canary
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: rollout-ref-canary
+spec:
+  replicas: 4
+  selector:
+    matchLabels:
+      app: rollout-ref-canary
+  template:
+    metadata:
+      labels:
+        app: rollout-ref-canary
+    spec:
+      containers:
+        - name: rollouts-demo
+          image: argoproj/rollouts-demo:green
+          resources:
+            requests:
+              memory: 16Mi
+              cpu: 1m
+`).
+		When().
+		ApplyManifests().
+		WaitForRolloutStatus("Healthy").
+    Sleep(3*time.Second). // Give time for Deployment to scale down
+		Then().
+		// Verify progressive migration completed - Deployment should be at 0
+		ExpectDeploymentReplicasCount("The Deployment has been scaled to 0 replicas after canary migration", "rollout-ref-canary", 0).
+		ExpectRollout("Rollout phase is healthy", func(r *v1alpha1.Rollout) bool {
+			return r.Status.Phase == v1alpha1.RolloutPhaseHealthy
+		}).
+		When().
+		// Simulate scaling event by increasing Rollout replicas
+    ScaleRolloutWithWorkloadRef(8).        
+		WaitForRolloutAvailableReplicas(8).
+		Sleep(3*time.Second). // Give time for any potential Deployment scaling
+		Then().
+		// Verify Deployment STILL stays at 0 replicas after scaling up
+		ExpectDeploymentReplicasCount("Deployment should remain at 0 replicas during canary scaling events", "rollout-ref-canary", 0).
+		ExpectReplicaCounts(8, 8, 8, 8, 8). // All replicas should come from Rollout
+		When().
+		// Test a canary update to ensure Deployment doesn't scale during revision changes
+		UpdateWorkloadRef("rollout-ref-canary").
+		WaitForRolloutStatus("Paused"). // Should pause at first step (25%)
+		PromoteRollout().
+		WaitForRolloutStatus("Paused"). // Should pause at second step (50%)
+		PromoteRollout().
+		WaitForRolloutStatus("Paused"). // Should pause at third step (75%)
+		PromoteRollout().
+		WaitForRolloutStatus("Healthy"). // Should complete
+		Sleep(2*time.Second).
+		Then().
+		// Even after canary update, Deployment should remain at 0
+		ExpectDeploymentReplicasCount("Deployment should remain at 0 replicas after canary update", "rollout-ref-canary", 0)
+}
+
 func (s *FunctionalSuite) TestNeverScaleDown() {
 	s.Given().
 		RolloutObjects(`
diff --git a/test/fixtures/when.go b/test/fixtures/when.go
index b4b7ed24..0bc66da9 100644
--- a/test/fixtures/when.go
+++ b/test/fixtures/when.go
@@ -147,6 +147,42 @@ func (w *When) UpdateSpec(texts ...string) *When {
 	return w
 }
 
+// UpdateWorkloadRef updates the workload referenced by the rollout (e.g., deployment)
+func (w *When) UpdateWorkloadRef(deploymentName string, texts ...string) *When {
+	if w.rollout == nil {
+		w.t.Fatal("Rollout not set")
+	}
+
+	currentRo, err := w.rolloutClient.ArgoprojV1alpha1().Rollouts(w.namespace).Get(w.Context, w.rollout.GetName(), metav1.GetOptions{})
+	w.CheckError(err)
+	if currentRo.Spec.WorkloadRef == nil {
+		w.t.Fatal("Rollout does not have workloadRef")
+	}
+
+	var patchBytes []byte
+	if len(texts) == 0 {
+		nowStr := time.Now().Format(time.RFC3339Nano)
+		patchBytes = []byte(fmt.Sprintf(`{"spec":{"template":{"metadata":{"annotations":{"update":"%s"}}}}}`, nowStr))
+		w.log.Infof("Updating workload ref deployment with timestamp: %s", nowStr)
+	} else {
+		var err error
+		patchBytes, err = yaml.YAMLToJSON([]byte(texts[0]))
+		w.CheckError(err)
+		w.log.Infof("Updating workload ref deployment: %s", string(patchBytes))
+	}
+
+	_, err = w.kubeClient.AppsV1().Deployments(w.namespace).Patch(
+		w.Context,
+		deploymentName,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+	w.CheckError(err)
+	w.log.Infof("Updated workload ref deployment: %s", deploymentName)
+	return w
+}
+
 func (w *When) PromoteRollout() *When {
 	if w.rollout == nil {
 		w.t.Fatal("Rollout not set")
@@ -218,6 +254,46 @@ func (w *When) ScaleRollout(scale int) *When {
 	return w
 }
 
+// ScaleRolloutWithWorkloadRef scales a rollout with workload reference using JSON patch
+// to ensure only the replicas field is modified and template remains untouched
+func (w *When) ScaleRolloutWithWorkloadRef(scale int) *When {
+	if w.rollout == nil {
+		w.t.Fatal("Rollout not set")
+	}
+
+	currentRo, err := w.rolloutClient.ArgoprojV1alpha1().Rollouts(w.namespace).Get(w.Context, w.rollout.GetName(), metav1.GetOptions{})
+	w.CheckError(err)
+	w.log.Infof("Current rollout replicas: %d, workloadRef: %v", *currentRo.Spec.Replicas, currentRo.Spec.WorkloadRef != nil)
+
+	// Create JSON patch that only modifies replicas
+	patch := []map[string]interface{}{
+		{
+			"op":    "replace",
+			"path":  "/spec/replicas",
+			"value": scale,
+		},
+	}
+
+	patchBytes, err := json.Marshal(patch)
+	w.CheckError(err)
+
+	_, err = w.rolloutClient.ArgoprojV1alpha1().Rollouts(w.namespace).Patch(
+		w.Context,
+		w.rollout.GetName(),
+		types.JSONPatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+	w.CheckError(err)
+
+	// Verify the patch was successful
+	updatedRo, err := w.rolloutClient.ArgoprojV1alpha1().Rollouts(w.namespace).Get(w.Context, w.rollout.GetName(), metav1.GetOptions{})
+	w.CheckError(err)
+	w.log.Infof("Scaled rollout with workload ref to %d (actual: %d)", scale, *updatedRo.Spec.Replicas)
+
+	return w
+}
+
 func (w *When) Sleep(d time.Duration) *When {
 	w.log.Infof("Sleeping %s", d)
 	time.Sleep(d)
diff --git a/utils/replicaset/canary_test.go b/utils/replicaset/canary_test.go
index a8e0c059..ad584e8a 100755
--- a/utils/replicaset/canary_test.go
+++ b/utils/replicaset/canary_test.go
@@ -1629,6 +1629,32 @@ func TestAllDesiredAreAvailable(t *testing.T) {
 			desired: 10,
 			want:    false,
 		},
+		{
+			name: "test correct return (false) when rs.Spec.Replicas is nil and desired != 1 (GetReplicasOrDefault returns 1)",
+			rs: &appsv1.ReplicaSet{
+				Status: appsv1.ReplicaSetStatus{
+					AvailableReplicas: 10,
+				},
+				Spec: appsv1.ReplicaSetSpec{
+					Replicas: nil, // nil replicas defaults to 1 by GetReplicasOrDefault
+				},
+			},
+			desired: 10,
+			want:    false,
+		},
+		{
+			name: "test correct return (true) when rs.Spec.Replicas is nil and desired is 1",
+			rs: &appsv1.ReplicaSet{
+				Status: appsv1.ReplicaSetStatus{
+					AvailableReplicas: 1,
+				},
+				Spec: appsv1.ReplicaSetSpec{
+					Replicas: nil, // nil replicas defaults to 1
+				},
+			},
+			desired: 1,
+			want:    true,
+		},
 	}
 
 	for _, tt := range tests {
@@ -1757,6 +1783,20 @@ func TestAllReplicaProgressThresholdMet(t *testing.T) {
 			desired: 10,
 			want:    true,
 		},
+		{
+			name: "test that when desired is 0, threshold is always met (percentage type only)",
+			threshold: &v1alpha1.ReplicaProgressThreshold{
+				Type:  v1alpha1.ProgressTypePercentage,
+				Value: 90,
+			},
+			rs: &appsv1.ReplicaSet{
+				Status: appsv1.ReplicaSetStatus{
+					AvailableReplicas: 0,
+				},
+			},
+			desired: 0,
+			want:    true,
+		},
 	}
 
 	for _, tt := range tests {