diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cbacdc1d..7e18f82d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `rsconnect deploy` commands now verify content before activating it. The new + bundle is deployed as a draft, its preview URL is accessed to confirm the + content starts, and only then is the bundle activated. If verification fails, + the bundle is left as a draft and the previously-active bundle keeps serving, + so a broken build never becomes the active version. `--draft` still deploys + without activating (and now verifies the draft rather than the active + content), and `--no-verify` skips verification and activates immediately. + Draft deploys require Connect 2025.06.0 or later; against older servers the + bundle is deployed and activated in one step and the active content is + verified, as before. - `rsconnect deploy` commands now check PyPI once a day for a newer release of rsconnect-python and print an upgrade hint to stderr when one is available. The result is cached so most invocations make no network request. Set diff --git a/rsconnect/api.py b/rsconnect/api.py index ef2aadf2..390df867 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -390,6 +390,7 @@ class RSConnectClientDeployResult(TypedDict): app_url: str dashboard_url: str draft_url: str | None + bundle_id: str | None title: str | None @@ -413,6 +414,29 @@ def server_supports_git_metadata(server_version: Optional[str]) -> bool: return False +def server_supports_draft_deploy(server_version: Optional[str]) -> bool: + """ + Check if the server supports deploying a bundle as a draft and activating it + separately, i.e. the ``activate`` field on the content deploy/build endpoints. + + Older servers reject the unknown field, so we must not send it to them. + + Draft deploys were added in Connect 2025.06.0. + + :param server_version: The Connect server version string + :return: True if the server supports draft deploys, False otherwise + """ + if not server_version: + return False + + try: + return compare_semvers(server_version, "2025.06.0") >= 0 + except Exception: + # If we can't parse the version, assume it doesn't support it + logger.debug(f"Unable to parse server version: {server_version}") + return False + + class RSConnectClient(HTTPServer): def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None): if cookies is None: @@ -599,10 +623,15 @@ def add_environment_vars(self, content_guid: str, env_vars: list[tuple[str, str] def is_failed_response(self, response: HTTPResponse | JsonData) -> bool: return isinstance(response, HTTPResponse) and response.status >= 500 - def access_content(self, content_guid: str) -> None: + def access_content(self, content_guid: str, bundle_id: Optional[str] = None) -> None: method = "GET" - base = dirname(self._url.path) # remove __api__ - path = f"{base}/content/{content_guid}/" + base = dirname(self._url.path).rstrip("/") # strip "__api__" and any trailing slash + # Access a specific (e.g. draft, not-yet-activated) bundle's preview URL when a + # bundle id is given. Connect spins the process up cold to serve this, so a + # successful response confirms the bundle actually runs without touching the + # active bundle. + suffix = f"_bundle{bundle_id}/" if bundle_id is not None else "" + path = f"{base}/content/{content_guid}/{suffix}" response = self._do_request(method, path, None, None, 3, {}, False) if self.is_failed_response(response): @@ -892,6 +921,7 @@ def deploy( "app_url": app["content_url"], "dashboard_url": app["dashboard_url"], "draft_url": draft_url if not activate else None, + "bundle_id": app_bundle["id"], "title": app["title"], } @@ -1025,6 +1055,7 @@ def __init__( self.bundle: IO[bytes] | None = None self.deployed_info: RSConnectClientDeployResult | None = None + self._draft_deploy_supported: bool | None = None self.logger: logging.Logger | None = logger self.ctx = ctx @@ -1405,6 +1436,7 @@ def deploy_bundle(self, activate: bool = True): app_guid=None, task_id=None, draft_url=None, + bundle_id=None, title=self.title, ) return self @@ -1472,6 +1504,39 @@ def save_deployed_info(self): return self + @property + def supports_verify_before_activate(self) -> bool: + """Whether the target server supports deploying a bundle as a draft and + activating it separately. shinyapps.io / Posit Cloud and pre-2025.06.0 Connect + do not, so for those we deploy and activate in one step and verify the active + content instead.""" + if not isinstance(self.client, RSConnectClient): + return False + if self._draft_deploy_supported is None: + try: + server_version = self.client.server_settings().get("version", "") + except Exception: + server_version = None + self._draft_deploy_supported = server_supports_draft_deploy(server_version) + return self._draft_deploy_supported + + def should_deploy_as_draft(self, draft: bool, no_verify: bool) -> bool: + """Whether the bundle should be deployed without activating it. + + An explicit ``--draft`` always deploys a draft. Otherwise we deploy a draft only + when we are going to verify it before activating, which requires server support. + With ``--no-verify`` we activate immediately. + """ + if draft: + if not self.supports_verify_before_activate: + # We can't honor --draft without the activate field: silently activating + # would be the opposite of what the user asked for, so fail loudly. + raise RSConnectException("Deploying as a draft requires Posit Connect 2025.06.0 or later.") + return True + if no_verify: + return False + return self.supports_verify_before_activate + @cls_logged("Verifying deployed content...") def verify_deployment(self): if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)): @@ -1479,7 +1544,34 @@ def verify_deployment(self): raise RSConnectException("To verify deployment, client must be a RSConnectClient.") deployed_info = self.deployed_info app_guid = deployed_info["app_guid"] - self.client.access_content(app_guid) + # If the bundle was deployed as a draft (not activated), verify the draft + # bundle's preview URL rather than the currently-active content. Otherwise a + # broken draft would be masked by a previously-working active bundle. + bundle_id = deployed_info.get("bundle_id") if deployed_info.get("draft_url") else None + self.client.access_content(app_guid, bundle_id=bundle_id) + return self + + @cls_logged("Activating deployed content...") + def activate_deployment(self): + """Activate the bundle deployed as a draft, e.g. after verifying it runs. + + This re-issues the deploy request for the same bundle with ``activate=True``, + which is what the "Activate Draft" button in the Connect UI does. + """ + if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)): + if not isinstance(self.client, RSConnectClient): + raise RSConnectException("To activate deployment, client must be a RSConnectClient.") + deployed_info = self.deployed_info + app_guid = deployed_info["app_guid"] + bundle_id = deployed_info["bundle_id"] + if app_guid is None or bundle_id is None: + raise RSConnectException("An app GUID and bundle ID are required to activate a deployment.") + task = self.client.content_deploy(app_guid, bundle_id, activate=True) + # Update deployed_info so a subsequent emit_task_log() waits on the activation + # task and reports the live content URLs instead of the draft URL. + deployed_info["task_id"] = task["task_id"] + deployed_info["draft_url"] = None + return self @cls_logged("Validating app mode...") def validate_app_mode(self, app_mode: AppMode): diff --git a/rsconnect/main.py b/rsconnect/main.py index f1142bb1..cf06db17 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -392,14 +392,17 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--no-verify", is_flag=True, - help="Don't access the deployed content to verify that it started correctly.", + help=( + "Don't access the deployed content to verify that it started correctly. " + "Implies activating the new bundle immediately rather than verifying it first." + ), ) @click.option( "--draft", is_flag=True, help=( - "Deploy the application as a draft. " - "Previous bundle will continue to be served until the draft is published." + "Deploy the application as a draft and verify it, but do not activate it. " + "The previous bundle will continue to be served until the draft is published." ), ) @click.option( @@ -1541,9 +1544,12 @@ def deploy_notebook( env_management_r=env_management_r, r_environment=r_environment, ) - ce.deploy_bundle(activate=not draft).save_deployed_info().emit_task_log() + ce.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)).save_deployed_info().emit_task_log() if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1709,9 +1715,12 @@ def deploy_voila( env_management_r=env_management_r, r_environment=r_environment, multi_notebook=multi_notebook, - ).deploy_bundle(activate=not draft).save_deployed_info().emit_task_log() + ).deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)).save_deployed_info().emit_task_log() if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() # noinspection SpellCheckingInspection,DuplicatedCode @@ -1797,12 +1806,15 @@ def deploy_manifest( make_manifest_bundle, file_name, ) - .deploy_bundle(activate=not draft) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) .save_deployed_info() .emit_task_log() ) if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() @deploy.command( @@ -1887,12 +1899,15 @@ def deploy_bundle( open_bundle, file, ) - .deploy_bundle(activate=not draft) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) .save_deployed_info() .emit_task_log() ) if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() @deploy.command( @@ -2095,12 +2110,15 @@ def quickstart_hint() -> str: ce.validate_server() .validate_app_mode(app_mode=app_mode) .make_bundle(bundle_builder, *bundle_args, **bundle_kwargs) - .deploy_bundle(activate=not draft) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) .save_deployed_info() .emit_task_log() ) if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2283,12 +2301,15 @@ def deploy_quarto( env_management_r=env_management_r, r_environment=r_environment, ) - .deploy_bundle(activate=not draft) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) .save_deployed_info() .emit_task_log() ) if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2389,12 +2410,15 @@ def deploy_tensorflow( exclude, image=image, ) - .deploy_bundle(activate=not draft) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) .save_deployed_info() .emit_task_log() ) if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() # noinspection SpellCheckingInspection,DuplicatedCode @@ -2513,12 +2537,15 @@ def deploy_html( extra_files, exclude, ) - .deploy_bundle(activate=not draft) + .deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) .save_deployed_info() .emit_task_log() ) if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() def resolve_requirements_file(directory: str, requirements_file: Optional[str], force_generate: bool) -> Optional[str]: @@ -2747,12 +2774,15 @@ def deploy_app( env_management_r=env_management_r, r_environment=r_environment, ) - ce.deploy_bundle(activate=not draft) + ce.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) ce.save_deployed_info() ce.emit_task_log() if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() return deploy_app @@ -2907,12 +2937,15 @@ def deploy_nodejs( image=image, env_management_node=env_management_node, ) - ce.deploy_bundle(activate=not draft) + ce.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)) ce.save_deployed_info() ce.emit_task_log() if not no_verify: ce.verify_deployment() + if not draft and ce.supports_verify_before_activate: + # The draft bundle verified successfully, so activate it. + ce.activate_deployment().emit_task_log() @deploy.command( diff --git a/tests/test_main.py b/tests/test_main.py index 62a3d0b3..7cc3d041 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import json import os +import re import shutil from os.path import join from unittest import TestCase, mock @@ -10,6 +11,7 @@ from click.testing import CliRunner from rsconnect import VERSION +from rsconnect.api import RSConnectClient, RSConnectServer from rsconnect.json_web_token import SECRET_KEY_ENV from rsconnect.main import cli, env_management_callback @@ -303,6 +305,309 @@ def post_application_deploy_callback(request, uri, response_headers): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_verify_before_activate(self, caplog): + # Without --draft or --no-verify, the default flow deploys the bundle as a + # draft (activate=false), verifies the draft bundle's preview URL, and only + # then activates it. The active bundle should never receive an unverified build. + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/server_settings", + body=json.dumps({"version": "9999.99.99"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content?name=app5", + body=json.dumps([]), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + content_body = json.dumps( + { + "id": "1234", + "guid": "1234-5678-9012-3456", + "title": "app5", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + } + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + body=json.dumps({"id": "FAKE_BUNDLE_ID"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + # Record the body of each deploy call so we can assert the draft-then-activate + # ordering. + deploy_bodies = [] + + def post_application_deploy_callback(request, uri, response_headers): + deploy_bodies.append(_load_json(request.body)) + return [201, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + body=post_application_deploy_callback, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/tasks/FAKE_TASK_ID" "?wait=1", + body=json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + # The verify step accesses the draft bundle's preview URL. A 200 confirms it runs. + verify_invoked = [] + + def get_bundle_preview_callback(request, uri, response_headers): + verify_invoked.append(uri) + return [200, {"Content-Type": "text/html"}, ""] + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/content/1234-5678-9012-3456/_bundleFAKE_BUNDLE_ID/", + body=get_bundle_preview_callback, + ) + + try: + runner = CliRunner() + args = apply_common_args( + ["deploy", "manifest", get_manifest_path("pyshiny_with_manifest", "")], + server="http://fake_server", + key="FAKE_API_KEY", + ) + with mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *args, **kwargs: self_, + ), caplog.at_level("INFO"): + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + # First deploy is a draft, then verify, then a second deploy that activates it. + assert deploy_bodies == [ + {"bundle_id": "FAKE_BUNDLE_ID", "activate": False}, + {"bundle_id": "FAKE_BUNDLE_ID"}, + ] + assert len(verify_invoked) == 1 + assert "Verifying deployed content..." in caplog.text + assert "Activating deployed content..." in caplog.text + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_verify_before_activate_unsupported_server(self, caplog): + # Servers older than 2025.06.0 reject the "activate" field, so we must not deploy + # a draft. Instead we deploy + activate in one step (no "activate" field sent) and + # verify the active content, as before draft support existed. + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/server_settings", + body=json.dumps({"version": "2025.03.0"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content?name=app5", + body=json.dumps([]), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + content_body = json.dumps( + { + "id": "1234", + "guid": "1234-5678-9012-3456", + "title": "app5", + "content_url": "http://fake_server/content/1234-5678-9012-3456", + "dashboard_url": "http://fake_server/connect/#/apps/1234-5678-9012-3456", + } + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456", + body=content_body, + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/bundles", + body=json.dumps({"id": "FAKE_BUNDLE_ID"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + deploy_bodies = [] + + def post_application_deploy_callback(request, uri, response_headers): + deploy_bodies.append(_load_json(request.body)) + return [201, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + body=post_application_deploy_callback, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/tasks/FAKE_TASK_ID" "?wait=1", + body=json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + # On an old server the verify step hits the *active* content URL, not a draft. + verify_invoked = [] + + def get_active_content_callback(request, uri, response_headers): + verify_invoked.append(uri) + return [200, {"Content-Type": "text/html"}, ""] + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/content/1234-5678-9012-3456/", + body=get_active_content_callback, + ) + + try: + runner = CliRunner() + args = apply_common_args( + ["deploy", "manifest", get_manifest_path("pyshiny_with_manifest", "")], + server="http://fake_server", + key="FAKE_API_KEY", + ) + with mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *args, **kwargs: self_, + ), caplog.at_level("INFO"): + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.output + # A single deploy that activates immediately: no "activate" field is sent, + # and there is no second (activation) deploy call. + assert deploy_bodies == [{"bundle_id": "FAKE_BUNDLE_ID"}] + # The active content was verified, and nothing was separately activated. + assert len(verify_invoked) == 1 + assert "Verifying deployed content..." in caplog.text + assert "Activating deployed content..." not in caplog.text + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_draft_unsupported_server(self): + # --draft cannot be honored on servers that reject the "activate" field, and + # silently activating would be the opposite of the user's intent, so the deploy + # fails with a clear error instead of sending an unsupported request. + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/server_settings", + body=json.dumps({"version": "2025.03.0"}), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + httpretty.register_uri( + httpretty.GET, + "http://fake_server/__api__/v1/content?name=app5", + body=json.dumps([]), + adding_headers={"Content-Type": "application/json"}, + status=200, + ) + + # The deploy endpoint must never be called: we should fail before sending it. + deploy_invoked = [] + + def post_application_deploy_callback(request, uri, response_headers): + deploy_invoked.append(uri) + return [201, {"Content-Type": "application/json"}, json.dumps({"task_id": "FAKE_TASK_ID"})] + + httpretty.register_uri( + httpretty.POST, + "http://fake_server/__api__/v1/content/1234-5678-9012-3456/deploy", + body=post_application_deploy_callback, + ) + + try: + runner = CliRunner() + args = apply_common_args( + ["deploy", "manifest", "--draft", get_manifest_path("pyshiny_with_manifest", "")], + server="http://fake_server", + key="FAKE_API_KEY", + ) + with mock.patch( + "rsconnect.api.RSConnectExecutor.validate_app_mode", + new=lambda self_, *args, **kwargs: self_, + ): + result = runner.invoke(cli, args) + assert result.exit_code != 0 + assert "Deploying as a draft requires Posit Connect 2025.06.0 or later." in result.output + assert deploy_invoked == [] + finally: + if original_api_key_value: + os.environ["CONNECT_API_KEY"] = original_api_key_value + if original_server_value: + os.environ["CONNECT_SERVER"] = original_server_value + @httpretty.activate(verbose=True, allow_net_connect=False) def test_deploy_bundle(self, caplog): # Deploying a downloaded bundle should upload the tarball as-is (no @@ -800,6 +1105,68 @@ def test_deploy_api_fail_no_verify(self): result = runner.invoke(cli, args) assert result.exit_code == 0, result.output + @staticmethod + def _deployed_content_guid(caplog): + # The deploy flow logs "Direct content URL: /content/". + match = re.search(r"/content/([0-9a-fA-F-]+)", caplog.text) + assert match is not None, "Could not find deployed content URL in:\n" + caplog.text + return match.group(1) + + def test_default_deploy_does_not_activate_broken_bundle(self, caplog): + # #769: the default flow deploys a draft, verifies it, and only then activates + # it. A broken redeploy must fail AND leave the previously-active (working) + # bundle serving. Without verify-before-activate, the broken bundle is activated + # before verification, so the active bundle becomes the broken one. + # Draft deploys (the activate field) require Connect 2025.06.0+. + require_connect_version("2025.06.0") + client = RSConnectClient(RSConnectServer(require_connect(), require_api_key())) + runner = CliRunner() + + # 1. Deploy a working app. It is verified and becomes the active bundle. + with caplog.at_level("INFO"): + good = runner.invoke(cli, self.create_deploy_args("api", get_api_path("flask"))) + assert good.exit_code == 0, good.output + guid = self._deployed_content_guid(caplog) + good_bundle_id = client.content_get(guid)["bundle_id"] + assert good_bundle_id is not None + + # 2. Redeploy a broken app to the SAME content with the default flow. + bad_args = self.create_deploy_args("api", get_api_path("flask-bad")) + bad_args.extend(["-e", "badapp", "--app-id", guid]) + bad = runner.invoke(cli, bad_args) + + # The command fails because the draft bundle does not start... + assert bad.exit_code == 1, bad.output + # ...and crucially, the active bundle is still the working one. + assert client.content_get(guid)["bundle_id"] == good_bundle_id + + def test_draft_deploy_verifies_the_draft_not_the_active_bundle(self, caplog): + # #768: with --draft, verification must target the draft bundle, not the + # currently-active content. Deploying a broken draft over a working active + # bundle must fail. Without the fix, --draft verified the still-good active + # content and reported success. + # Draft deploys (the activate field) require Connect 2025.06.0+. + require_connect_version("2025.06.0") + client = RSConnectClient(RSConnectServer(require_connect(), require_api_key())) + runner = CliRunner() + + # 1. Deploy a working app; it becomes the active bundle. + with caplog.at_level("INFO"): + good = runner.invoke(cli, self.create_deploy_args("api", get_api_path("flask"))) + assert good.exit_code == 0, good.output + guid = self._deployed_content_guid(caplog) + good_bundle_id = client.content_get(guid)["bundle_id"] + + # 2. Deploy a broken app as a draft to the same content. + draft_args = self.create_deploy_args("api", get_api_path("flask-bad")) + draft_args.extend(["-e", "badapp", "--app-id", guid, "--draft"]) + draft = runner.invoke(cli, draft_args) + + # Verification of the broken draft fails... + assert draft.exit_code == 1, draft.output + # ...and the draft was never activated, so the active bundle is unchanged. + assert client.content_get(guid)["bundle_id"] == good_bundle_id + def test_add_connect(self): connect_server = require_connect() api_key = require_api_key()