Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 96 additions & 4 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ class RSConnectClientDeployResult(TypedDict):
app_url: str
dashboard_url: str
draft_url: str | None
bundle_id: str | None
title: str | None


Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"],
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1472,14 +1504,74 @@ 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)):
if not isinstance(self.client, RSConnectClient):
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):
Expand Down
59 changes: 46 additions & 13 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,14 +393,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(
Expand Down Expand Up @@ -1659,9 +1662,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
Expand Down Expand Up @@ -1827,9 +1833,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
Expand Down Expand Up @@ -1915,12 +1924,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(
Expand Down Expand Up @@ -2005,12 +2017,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(
Expand Down Expand Up @@ -2213,12 +2228,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
Expand Down Expand Up @@ -2401,12 +2419,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
Expand Down Expand Up @@ -2507,12 +2528,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
Expand Down Expand Up @@ -2631,12 +2655,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]:
Expand Down Expand Up @@ -2865,12 +2892,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

Expand Down Expand Up @@ -3025,12 +3055,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(
Expand Down
Loading
Loading