Skip to content

Commit 116a032

Browse files
feat: verify deployment on a draft, activate on success (#805)
* feat: verify deployment on a draft, activate on success (#769) Deploy commands now deploy the new bundle as a draft (activate=false), verify it by accessing its preview URL, and only activate it once the verification succeeds. 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. This also fixes #768: --draft now verifies the draft bundle rather than the currently-active content. --no-verify retains the old behavior of activating immediately without verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: avoid double slash in access_content preview path dirname("/__api__") returns "/", so joining it with "/content/..." produced a "//content/..." path. Connect normalizes it, but it is sloppy and made the verification request URL awkward to register in tests. Strip the trailing slash from the base so root-hosted and path-prefixed servers both produce a clean single-slash path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: integration tests for verify-before-activate Add two live-Connect integration tests (run via posit-dev/with-connect) that exercise the verify-before-activate guarantees and are expected to fail without the changes on this branch: - test_default_deploy_does_not_activate_broken_bundle (#769): a broken redeploy fails and leaves the previously-active working bundle serving; without the change the broken bundle is activated before verification. - test_draft_deploy_verifies_the_draft_not_the_active_bundle (#768): deploying a broken draft fails verification; without the change --draft verified the still-good active content and reported success. Both reuse the existing flask / flask-bad fixtures (flask-bad builds fine but raises at startup). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: version-gate the verify-before-activate flow Connect added the `activate` field on the deploy endpoint in 2025.06.0; older servers reject the unknown field, which broke every deploy against them once verify-before-activate became the default. Gate the draft-first flow on server version: on Connect >= 2025.06.0 we deploy a draft, verify it, and activate on success; on older servers (and shinyapps.io) we deploy and activate in one step and verify the active content, as before. Add a unit test for the fallback path on an unsupported server, and bump the two live integration tests to require Connect 2025.06.0 since they assert the verify-before-activate behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: error clearly when --draft used against unsupported Connect should_deploy_as_draft() returned True for --draft regardless of server support, so --draft against Connect < 2025.06.0 still sent activate:false and failed with a cryptic "unknown field" error. Raise a clear RSConnectException instead; silently downgrading to a non-draft deploy would activate the bundle, the opposite of --draft's intent. Add a unit test for --draft on an unsupported server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0e95952 commit 116a032

4 files changed

Lines changed: 519 additions & 17 deletions

File tree

docs/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
- `rsconnect deploy` commands now verify content before activating it. The new
11+
bundle is deployed as a draft, its preview URL is accessed to confirm the
12+
content starts, and only then is the bundle activated. If verification fails,
13+
the bundle is left as a draft and the previously-active bundle keeps serving,
14+
so a broken build never becomes the active version. `--draft` still deploys
15+
without activating (and now verifies the draft rather than the active
16+
content), and `--no-verify` skips verification and activates immediately.
17+
Draft deploys require Connect 2025.06.0 or later; against older servers the
18+
bundle is deployed and activated in one step and the active content is
19+
verified, as before.
1020
- `rsconnect deploy` commands now check PyPI once a day for a newer release of
1121
rsconnect-python and print an upgrade hint to stderr when one is available.
1222
The result is cached so most invocations make no network request. Set

rsconnect/api.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ class RSConnectClientDeployResult(TypedDict):
390390
app_url: str
391391
dashboard_url: str
392392
draft_url: str | None
393+
bundle_id: str | None
393394
title: str | None
394395

395396

@@ -413,6 +414,29 @@ def server_supports_git_metadata(server_version: Optional[str]) -> bool:
413414
return False
414415

415416

417+
def server_supports_draft_deploy(server_version: Optional[str]) -> bool:
418+
"""
419+
Check if the server supports deploying a bundle as a draft and activating it
420+
separately, i.e. the ``activate`` field on the content deploy/build endpoints.
421+
422+
Older servers reject the unknown field, so we must not send it to them.
423+
424+
Draft deploys were added in Connect 2025.06.0.
425+
426+
:param server_version: The Connect server version string
427+
:return: True if the server supports draft deploys, False otherwise
428+
"""
429+
if not server_version:
430+
return False
431+
432+
try:
433+
return compare_semvers(server_version, "2025.06.0") >= 0
434+
except Exception:
435+
# If we can't parse the version, assume it doesn't support it
436+
logger.debug(f"Unable to parse server version: {server_version}")
437+
return False
438+
439+
416440
class RSConnectClient(HTTPServer):
417441
def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None):
418442
if cookies is None:
@@ -599,10 +623,15 @@ def add_environment_vars(self, content_guid: str, env_vars: list[tuple[str, str]
599623
def is_failed_response(self, response: HTTPResponse | JsonData) -> bool:
600624
return isinstance(response, HTTPResponse) and response.status >= 500
601625

602-
def access_content(self, content_guid: str) -> None:
626+
def access_content(self, content_guid: str, bundle_id: Optional[str] = None) -> None:
603627
method = "GET"
604-
base = dirname(self._url.path) # remove __api__
605-
path = f"{base}/content/{content_guid}/"
628+
base = dirname(self._url.path).rstrip("/") # strip "__api__" and any trailing slash
629+
# Access a specific (e.g. draft, not-yet-activated) bundle's preview URL when a
630+
# bundle id is given. Connect spins the process up cold to serve this, so a
631+
# successful response confirms the bundle actually runs without touching the
632+
# active bundle.
633+
suffix = f"_bundle{bundle_id}/" if bundle_id is not None else ""
634+
path = f"{base}/content/{content_guid}/{suffix}"
606635
response = self._do_request(method, path, None, None, 3, {}, False)
607636

608637
if self.is_failed_response(response):
@@ -892,6 +921,7 @@ def deploy(
892921
"app_url": app["content_url"],
893922
"dashboard_url": app["dashboard_url"],
894923
"draft_url": draft_url if not activate else None,
924+
"bundle_id": app_bundle["id"],
895925
"title": app["title"],
896926
}
897927

@@ -1025,6 +1055,7 @@ def __init__(
10251055

10261056
self.bundle: IO[bytes] | None = None
10271057
self.deployed_info: RSConnectClientDeployResult | None = None
1058+
self._draft_deploy_supported: bool | None = None
10281059

10291060
self.logger: logging.Logger | None = logger
10301061
self.ctx = ctx
@@ -1405,6 +1436,7 @@ def deploy_bundle(self, activate: bool = True):
14051436
app_guid=None,
14061437
task_id=None,
14071438
draft_url=None,
1439+
bundle_id=None,
14081440
title=self.title,
14091441
)
14101442
return self
@@ -1472,14 +1504,74 @@ def save_deployed_info(self):
14721504

14731505
return self
14741506

1507+
@property
1508+
def supports_verify_before_activate(self) -> bool:
1509+
"""Whether the target server supports deploying a bundle as a draft and
1510+
activating it separately. shinyapps.io / Posit Cloud and pre-2025.06.0 Connect
1511+
do not, so for those we deploy and activate in one step and verify the active
1512+
content instead."""
1513+
if not isinstance(self.client, RSConnectClient):
1514+
return False
1515+
if self._draft_deploy_supported is None:
1516+
try:
1517+
server_version = self.client.server_settings().get("version", "")
1518+
except Exception:
1519+
server_version = None
1520+
self._draft_deploy_supported = server_supports_draft_deploy(server_version)
1521+
return self._draft_deploy_supported
1522+
1523+
def should_deploy_as_draft(self, draft: bool, no_verify: bool) -> bool:
1524+
"""Whether the bundle should be deployed without activating it.
1525+
1526+
An explicit ``--draft`` always deploys a draft. Otherwise we deploy a draft only
1527+
when we are going to verify it before activating, which requires server support.
1528+
With ``--no-verify`` we activate immediately.
1529+
"""
1530+
if draft:
1531+
if not self.supports_verify_before_activate:
1532+
# We can't honor --draft without the activate field: silently activating
1533+
# would be the opposite of what the user asked for, so fail loudly.
1534+
raise RSConnectException("Deploying as a draft requires Posit Connect 2025.06.0 or later.")
1535+
return True
1536+
if no_verify:
1537+
return False
1538+
return self.supports_verify_before_activate
1539+
14751540
@cls_logged("Verifying deployed content...")
14761541
def verify_deployment(self):
14771542
if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
14781543
if not isinstance(self.client, RSConnectClient):
14791544
raise RSConnectException("To verify deployment, client must be a RSConnectClient.")
14801545
deployed_info = self.deployed_info
14811546
app_guid = deployed_info["app_guid"]
1482-
self.client.access_content(app_guid)
1547+
# If the bundle was deployed as a draft (not activated), verify the draft
1548+
# bundle's preview URL rather than the currently-active content. Otherwise a
1549+
# broken draft would be masked by a previously-working active bundle.
1550+
bundle_id = deployed_info.get("bundle_id") if deployed_info.get("draft_url") else None
1551+
self.client.access_content(app_guid, bundle_id=bundle_id)
1552+
return self
1553+
1554+
@cls_logged("Activating deployed content...")
1555+
def activate_deployment(self):
1556+
"""Activate the bundle deployed as a draft, e.g. after verifying it runs.
1557+
1558+
This re-issues the deploy request for the same bundle with ``activate=True``,
1559+
which is what the "Activate Draft" button in the Connect UI does.
1560+
"""
1561+
if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
1562+
if not isinstance(self.client, RSConnectClient):
1563+
raise RSConnectException("To activate deployment, client must be a RSConnectClient.")
1564+
deployed_info = self.deployed_info
1565+
app_guid = deployed_info["app_guid"]
1566+
bundle_id = deployed_info["bundle_id"]
1567+
if app_guid is None or bundle_id is None:
1568+
raise RSConnectException("An app GUID and bundle ID are required to activate a deployment.")
1569+
task = self.client.content_deploy(app_guid, bundle_id, activate=True)
1570+
# Update deployed_info so a subsequent emit_task_log() waits on the activation
1571+
# task and reports the live content URLs instead of the draft URL.
1572+
deployed_info["task_id"] = task["task_id"]
1573+
deployed_info["draft_url"] = None
1574+
return self
14831575

14841576
@cls_logged("Validating app mode...")
14851577
def validate_app_mode(self, app_mode: AppMode):

rsconnect/main.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,14 +392,17 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]:
392392
@click.option(
393393
"--no-verify",
394394
is_flag=True,
395-
help="Don't access the deployed content to verify that it started correctly.",
395+
help=(
396+
"Don't access the deployed content to verify that it started correctly. "
397+
"Implies activating the new bundle immediately rather than verifying it first."
398+
),
396399
)
397400
@click.option(
398401
"--draft",
399402
is_flag=True,
400403
help=(
401-
"Deploy the application as a draft. "
402-
"Previous bundle will continue to be served until the draft is published."
404+
"Deploy the application as a draft and verify it, but do not activate it. "
405+
"The previous bundle will continue to be served until the draft is published."
403406
),
404407
)
405408
@click.option(
@@ -1541,9 +1544,12 @@ def deploy_notebook(
15411544
env_management_r=env_management_r,
15421545
r_environment=r_environment,
15431546
)
1544-
ce.deploy_bundle(activate=not draft).save_deployed_info().emit_task_log()
1547+
ce.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)).save_deployed_info().emit_task_log()
15451548
if not no_verify:
15461549
ce.verify_deployment()
1550+
if not draft and ce.supports_verify_before_activate:
1551+
# The draft bundle verified successfully, so activate it.
1552+
ce.activate_deployment().emit_task_log()
15471553

15481554

15491555
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -1709,9 +1715,12 @@ def deploy_voila(
17091715
env_management_r=env_management_r,
17101716
r_environment=r_environment,
17111717
multi_notebook=multi_notebook,
1712-
).deploy_bundle(activate=not draft).save_deployed_info().emit_task_log()
1718+
).deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify)).save_deployed_info().emit_task_log()
17131719
if not no_verify:
17141720
ce.verify_deployment()
1721+
if not draft and ce.supports_verify_before_activate:
1722+
# The draft bundle verified successfully, so activate it.
1723+
ce.activate_deployment().emit_task_log()
17151724

17161725

17171726
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -1797,12 +1806,15 @@ def deploy_manifest(
17971806
make_manifest_bundle,
17981807
file_name,
17991808
)
1800-
.deploy_bundle(activate=not draft)
1809+
.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
18011810
.save_deployed_info()
18021811
.emit_task_log()
18031812
)
18041813
if not no_verify:
18051814
ce.verify_deployment()
1815+
if not draft and ce.supports_verify_before_activate:
1816+
# The draft bundle verified successfully, so activate it.
1817+
ce.activate_deployment().emit_task_log()
18061818

18071819

18081820
@deploy.command(
@@ -1887,12 +1899,15 @@ def deploy_bundle(
18871899
open_bundle,
18881900
file,
18891901
)
1890-
.deploy_bundle(activate=not draft)
1902+
.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
18911903
.save_deployed_info()
18921904
.emit_task_log()
18931905
)
18941906
if not no_verify:
18951907
ce.verify_deployment()
1908+
if not draft and ce.supports_verify_before_activate:
1909+
# The draft bundle verified successfully, so activate it.
1910+
ce.activate_deployment().emit_task_log()
18961911

18971912

18981913
@deploy.command(
@@ -2095,12 +2110,15 @@ def quickstart_hint() -> str:
20952110
ce.validate_server()
20962111
.validate_app_mode(app_mode=app_mode)
20972112
.make_bundle(bundle_builder, *bundle_args, **bundle_kwargs)
2098-
.deploy_bundle(activate=not draft)
2113+
.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
20992114
.save_deployed_info()
21002115
.emit_task_log()
21012116
)
21022117
if not no_verify:
21032118
ce.verify_deployment()
2119+
if not draft and ce.supports_verify_before_activate:
2120+
# The draft bundle verified successfully, so activate it.
2121+
ce.activate_deployment().emit_task_log()
21042122

21052123

21062124
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -2283,12 +2301,15 @@ def deploy_quarto(
22832301
env_management_r=env_management_r,
22842302
r_environment=r_environment,
22852303
)
2286-
.deploy_bundle(activate=not draft)
2304+
.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
22872305
.save_deployed_info()
22882306
.emit_task_log()
22892307
)
22902308
if not no_verify:
22912309
ce.verify_deployment()
2310+
if not draft and ce.supports_verify_before_activate:
2311+
# The draft bundle verified successfully, so activate it.
2312+
ce.activate_deployment().emit_task_log()
22922313

22932314

22942315
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -2389,12 +2410,15 @@ def deploy_tensorflow(
23892410
exclude,
23902411
image=image,
23912412
)
2392-
.deploy_bundle(activate=not draft)
2413+
.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
23932414
.save_deployed_info()
23942415
.emit_task_log()
23952416
)
23962417
if not no_verify:
23972418
ce.verify_deployment()
2419+
if not draft and ce.supports_verify_before_activate:
2420+
# The draft bundle verified successfully, so activate it.
2421+
ce.activate_deployment().emit_task_log()
23982422

23992423

24002424
# noinspection SpellCheckingInspection,DuplicatedCode
@@ -2513,12 +2537,15 @@ def deploy_html(
25132537
extra_files,
25142538
exclude,
25152539
)
2516-
.deploy_bundle(activate=not draft)
2540+
.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
25172541
.save_deployed_info()
25182542
.emit_task_log()
25192543
)
25202544
if not no_verify:
25212545
ce.verify_deployment()
2546+
if not draft and ce.supports_verify_before_activate:
2547+
# The draft bundle verified successfully, so activate it.
2548+
ce.activate_deployment().emit_task_log()
25222549

25232550

25242551
def resolve_requirements_file(directory: str, requirements_file: Optional[str], force_generate: bool) -> Optional[str]:
@@ -2747,12 +2774,15 @@ def deploy_app(
27472774
env_management_r=env_management_r,
27482775
r_environment=r_environment,
27492776
)
2750-
ce.deploy_bundle(activate=not draft)
2777+
ce.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
27512778
ce.save_deployed_info()
27522779
ce.emit_task_log()
27532780

27542781
if not no_verify:
27552782
ce.verify_deployment()
2783+
if not draft and ce.supports_verify_before_activate:
2784+
# The draft bundle verified successfully, so activate it.
2785+
ce.activate_deployment().emit_task_log()
27562786

27572787
return deploy_app
27582788

@@ -2907,12 +2937,15 @@ def deploy_nodejs(
29072937
image=image,
29082938
env_management_node=env_management_node,
29092939
)
2910-
ce.deploy_bundle(activate=not draft)
2940+
ce.deploy_bundle(activate=not ce.should_deploy_as_draft(draft, no_verify))
29112941
ce.save_deployed_info()
29122942
ce.emit_task_log()
29132943

29142944
if not no_verify:
29152945
ce.verify_deployment()
2946+
if not draft and ce.supports_verify_before_activate:
2947+
# The draft bundle verified successfully, so activate it.
2948+
ce.activate_deployment().emit_task_log()
29162949

29172950

29182951
@deploy.command(

0 commit comments

Comments
 (0)