diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ba110352..26a00bd8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `rsconnect bootstrap` uses the `/__api__/v1/bootstrap` endpoint, falling back to the deprecated `/__api__/v1/experimental/bootstrap` endpoint on Posit Connect versions before 2024.05.0. (#594) +- `rsconnect deploy` deploys and activates rendered and static content (R Markdown, + Quarto, Jupyter notebooks, and HTML) in one step, without verifying a draft + first. Activating a draft makes Connect render it a second time, and if the + render fails, Connect keeps serving the previously-active bundle. With `--draft`, + rendered and static content is deployed as a draft but not verified. (#872) ## [1.32.0] - 2026-10-16 diff --git a/rsconnect/api.py b/rsconnect/api.py index 36315c01..3453c981 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -492,6 +492,16 @@ def server_supports_git_metadata(server_version: Optional[str]) -> bool: return False +# App modes that Connect renders (or serves as-is) at deploy time rather than running +# as a process. See RSConnectExecutor.is_rendered_content. +_RENDERED_APP_MODES = { + AppModes.RMD.name(), + AppModes.STATIC.name(), + AppModes.JUPYTER_NOTEBOOK.name(), + AppModes.STATIC_QUARTO.name(), +} + + def server_supports_draft_deploy(server_version: Optional[str]) -> bool: """ Check if the server supports deploying a bundle as a draft and activating it @@ -2698,12 +2708,33 @@ def supports_verify_before_activate(self) -> bool: self._draft_deploy_supported = server_supports_draft_deploy(server_version) return self._draft_deploy_supported + @property + def is_rendered_content(self) -> bool: + """Whether the bundle is rendered or static content rather than a running app. + + Connect renders a document when its bundle is deployed and renders it again + when a draft is activated, so verifying a draft first would render it twice. + Requesting the rendered output also can't catch anything the deploy task's + render didn't already, and Connect reverts to the previous bundle if an + activating render fails. + """ + app_mode = self.bundle_manifest().get("metadata", {}).get("appmode") + return app_mode in _RENDERED_APP_MODES + + def should_verify(self, no_verify: bool) -> bool: + """Whether to verify the deployed content. + + Rendered content is never verified, even as a draft: requesting a draft's + rendered output only serves what the deploy task already rendered. + """ + return not no_verify and not self.is_rendered_content + 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. + With ``--no-verify``, or for rendered content, we activate immediately. """ if draft: if not isinstance(self.client, RSConnectClient): @@ -2715,10 +2746,21 @@ def should_deploy_as_draft(self, draft: bool, no_verify: bool) -> bool: # 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: + if not self.should_verify(no_verify): return False return self.supports_verify_before_activate + def verify_and_activate(self, draft: bool, no_verify: bool): + """Verify the deployed content, then activate it if it was deployed as a + draft only so that it could be verified first.""" + if not self.should_verify(no_verify): + return self + self.verify_deployment() + if not draft and self.deployed_info and self.deployed_info.get("draft_url"): + # The draft bundle verified successfully, so activate it. + self.activate_deployment().emit_task_log() + return self + @cls_logged("Verifying deployed content...") def verify_deployment(self): if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)): diff --git a/rsconnect/main.py b/rsconnect/main.py index 22cb6bfb..013cbcf1 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -735,14 +735,16 @@ def publish_args(func: Callable[P, T]) -> Callable[P, T]: is_flag=True, help=( "Don't access the deployed content to verify that it started correctly. " - "Implies activating the new bundle immediately rather than verifying it first." + "Implies activating the new bundle immediately rather than verifying it first. " + "Rendered and static content is never verified." ), ) @click.option( "--draft", is_flag=True, help=( - "Deploy the application as a draft and verify it, but do not activate it. " + "Deploy the application as a draft and verify it (unless it is rendered or static content), " + "but do not activate it. " "The previous bundle will continue to be served until the draft is published." ), ) @@ -2253,11 +2255,7 @@ def deploy_notebook( r_environment=r_environment, ) 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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -2427,11 +2425,7 @@ def deploy_voila( r_environment=r_environment, multi_notebook=multi_notebook, ).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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -2535,11 +2529,7 @@ def deploy_manifest( .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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -2642,11 +2632,7 @@ def deploy_bundle( .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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -2869,11 +2855,7 @@ def quickstart_hint() -> str: .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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -3174,11 +3156,7 @@ def deploy_quarto( .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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -3286,11 +3264,7 @@ def deploy_tensorflow( .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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -3429,11 +3403,7 @@ def deploy_html( .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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() @@ -3681,11 +3651,7 @@ def deploy_app( 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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() return deploy_app @@ -3847,11 +3813,7 @@ def deploy_nodejs( 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() + ce.verify_and_activate(draft, no_verify) ce.emit_content_url() diff --git a/tests/test_main.py b/tests/test_main.py index d208d36d..54e94905 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -530,6 +530,71 @@ def get_bundle_preview_callback(request, uri, response_headers): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value + @pytest.mark.parametrize("draft", [False, True]) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_deploy_rendered_content_without_verifying(self, caplog, draft): + # #872: Connect renders a draft and renders it again on activation, so rendered + # content is deployed and activated in one step, without verifying a draft. + # With --draft it is deployed as a draft, but still not verified: the draft + # URL only serves what the deploy task already rendered. + original_api_key_value = os.environ.pop("CONNECT_API_KEY", None) + original_server_value = os.environ.pop("CONNECT_SERVER", None) + + self._register_quiet_deploy_routes( + json.dumps({"output": ["FAKE_OUTPUT"], "last": "FAKE_LAST", "finished": True, "code": 0}) + ) + 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, + ) + verify_invoked = [] + + def get_content_callback(request, uri, response_headers): + verify_invoked.append(uri) + return [200, {"Content-Type": "text/html"}, ""] + + httpretty.register_uri( + httpretty.GET, + re.compile(r"http://fake_server/content/1234-5678-9012-3456/"), + body=get_content_callback, + ) + + try: + runner = CliRunner() + args = apply_common_args( + ["deploy", "notebook", get_dir(join("pip1", "dummy.ipynb"))], + server="http://fake_server", + key="FAKE_API_KEY", + ) + if draft: + args.append("--draft") + 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 + if draft: + assert deploy_bodies == [{"bundle_id": "FAKE_BUNDLE_ID", "activate": False}] + else: + # A single deploy that activates: no "activate" field is sent. + assert deploy_bodies == [{"bundle_id": "FAKE_BUNDLE_ID"}] + assert verify_invoked == [] + assert "Verifying deployed content..." not 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_verify_before_activate_unsupported_server(self, caplog): # Servers older than 2025.06.0 reject the "activate" field, so we must not deploy @@ -873,10 +938,11 @@ def test_deploy_quiet_verify_failure_keeps_stdout_clean(self): body="", status=502, ) - target = get_dir(join("pip1", "dummy.ipynb")) + # Rendered content such as a notebook isn't verified, so deploy an app. + target = get_manifest_path("pyshiny_with_manifest", "") try: runner = CliRunner(mix_stderr=False) - args = apply_common_args(["deploy", "notebook", target], server="http://fake_server", key="FAKE_API_KEY") + args = apply_common_args(["deploy", "manifest", target], server="http://fake_server", key="FAKE_API_KEY") args += ["--quiet"] with mock.patch( "rsconnect.api.RSConnectExecutor.validate_app_mode",