Repository: meltano/meltano

Issue:
bug: meltano-cloud deployment update error when no changes to manifest and `--force` is not passed
### Meltano Version

Meltano Cloud version 2.17.1

### Python Version

3.10

### Bug scope

CLI (options, error messages, logging, etc.)

### Operating System

MacOS

### Description

Running `meltano-cloud deployment update --name staging` for example on a deployment that contains no changes to the manifest produces an error unless the `--force` flag is passed with the command. 



### Code

```python
meltano-cloud deployment update
Deployment name: staging
⠇ Updating deployment - this may take several minutes...
⠙ Updating deployment - this may take several minutes...
2023-06-02 15:24:13 [error    ] An unexpected error occurred.  
Traceback (most recent call last):
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/requests/models.py", line 971, in json
    return complexjson.loads(self.text, **kwargs)
  File "/<path>/.pyenv/versions/3.10.4/lib/python3.10/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/<path>/.pyenv/versions/3.10.4/lib/python3.10/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/<path>/.pyenv/versions/3.10.4/lib/python3.10/json/decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/meltano/cloud/cli/__init__.py", line 43, in main
    cloud()
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 1130, in __call__
    return self.main(*args, **kwargs)
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/decorators.py", line 84, in new_func
    return ctx.invoke(f, obj, *args, **kwargs)
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/meltano/cloud/cli/base.py", line 36, in wrapper
    return asyncio.run(f(*args, **kwargs))
  File "/<path>/.pyenv/versions/3.10.4/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/<path>/.pyenv/versions/3.10.4/lib/python3.10/asyncio/base_events.py", line 646, in run_until_complete
    return future.result()
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/meltano/cloud/cli/deployment.py", line 449, in update_deployment
    updated_deployment = await client.update_deployment(
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/meltano/cloud/cli/deployment.py", line 138, in update_deployment
    deployment = response.json()
  File "/<path>/.local/pipx/venvs/meltano-cloud-cli/lib/python3.10/site-packages/requests/models.py", line 975, in json
    raise RequestsJSONDecodeError(e.msg, e.doc, e.pos)
requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
```



Discussion:
[Related backend](https://github.com/meltano/infra/blob/f60ed01830345932ab3b475c613adfe235e8d4bc/services/provisioner/provisioner_api/v1.py#L107). 

Resolving patch:
diff --git a/src/cloud-cli/meltano/cloud/cli/deployment.py b/src/cloud-cli/meltano/cloud/cli/deployment.py
--- a/src/cloud-cli/meltano/cloud/cli/deployment.py
+++ b/src/cloud-cli/meltano/cloud/cli/deployment.py
@@ -17,11 +17,11 @@
 from yaspin import yaspin  # type: ignore
 
 from meltano.cloud.api.client import MeltanoCloudClient, MeltanoCloudError
+from meltano.cloud.api.types import CloudDeployment
 from meltano.cloud.cli.base import pass_context, run_async
 
 if t.TYPE_CHECKING:
     from meltano.cloud.api.config import MeltanoCloudConfig
-    from meltano.cloud.api.types import CloudDeployment
     from meltano.cloud.cli.base import MeltanoCloudCLIContext
 
 DEFAULT_GET_DEPLOYMENTS_LIMIT = 125
@@ -102,7 +102,7 @@ async def update_deployment(
         git_rev: str,
         force: bool = False,
         preserve_git_hash: bool = False,
-    ) -> CloudDeployment:
+    ) -> CloudDeployment | None:
         """Use POST to update a Meltano Cloud deployment.
 
         Args:
@@ -135,13 +135,17 @@ async def update_deployment(
             )
         response = requests.request(**t.cast(t.Dict[str, t.Any], prepared_request))
         response.raise_for_status()
+        if response.status_code == HTTPStatus.NO_CONTENT:
+            return None
         deployment = response.json()
-        return {
-            **deployment,  # type: ignore[misc]
-            "default": (
-                self.config.default_deployment_name == deployment["deployment_name"]
-            ),
-        }
+        return CloudDeployment(
+            {
+                **deployment,  # type: ignore[misc]
+                "default": (
+                    self.config.default_deployment_name == deployment["deployment_name"]
+                ),
+            },
+        )
 
     async def delete_deployment(self, deployment_name: str) -> None:
         """Use DELETE to delete a Meltano Cloud deployment.
@@ -390,10 +394,13 @@ async def create_deployment(
                 environment_name=environment_name,
                 git_rev=git_rev,
             )
-        click.secho(
-            f"Created deployment {deployment['deployment_name']!r}",
-            fg="green",
-        )
+        if deployment is None:
+            click.secho("Deployment already exists, and was not updated.", fg="yellow")
+        else:
+            click.secho(
+                f"Created deployment {deployment['deployment_name']!r}",
+                fg="green",
+            )
 
 
 @deployment_group.command(
@@ -453,10 +460,20 @@ async def update_deployment(  # noqa: D103
                 force=force,
                 preserve_git_hash=preserve_git_hash,
             )
-    click.secho(
-        f"Updated deployment {updated_deployment['deployment_name']!r}",
-        fg="green",
-    )
+    if updated_deployment is None:
+        click.secho(
+            (
+                "Deployment already up-to-date. No changes to the Meltano "
+                "manifest were detected. Use the '--force' option to perform "
+                "an update anyway."
+            ),
+            fg="yellow",
+        )
+    else:
+        click.secho(
+            f"Updated deployment {updated_deployment['deployment_name']!r}",
+            fg="green",
+        )
 
 
 @deployment_group.command("delete")


Test patch: