Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 44 additions & 2 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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)):
Expand Down
66 changes: 14 additions & 52 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
)
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down
70 changes: 68 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}, "<html></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
Expand Down Expand Up @@ -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",
Expand Down
Loading