Skip to content

feat: add support for deploying to Posit Connect Cloud - #837

Draft
samperman wants to merge 3 commits into
mainfrom
connect-cloud
Draft

feat: add support for deploying to Posit Connect Cloud#837
samperman wants to merge 3 commits into
mainfrom
connect-cloud

Conversation

@samperman

@samperman samperman commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Intent

Add Posit Connect Cloud as a deployment target, alongside Posit Connect and shinyapps.io, mirroring the R rsconnect package's support.

Fixes #817

Type of Change

  • Bug Fix
  • New Feature
  • Breaking Change

Approach

  • Target selection: --connect-cloud (or -s connect.posit.cloud) with -A <account>, or a saved nickname via -n. The production, staging, and development environments are selected with CONNECT_CLOUD_ENVIRONMENT and pinned to the saved server's URL.
  • Authentication (oauth.py, connect_cloud.py): interactive OAuth device-code login by default; service-account client ID/secret (client-credentials grant) via --client-id/--client-secret or CONNECT_CLOUD_CLIENT_ID/CONNECT_CLOUD_CLIENT_SECRET for CI. Tokens refresh automatically on 401 and the rotated refresh token is written back to the credential store.
  • Credential registration: rsconnect add verifies the account exists and grants the content:create permission before storing.
  • Deployment (api.py): follows the Connect Cloud revision model — create or update content, upload the bundle to a presigned URL, publish, poll the revision, and print the publish log from the logs service on failure. Deployments are recorded locally before publishing because Connect Cloud cannot look content up by name.
  • Credential redaction (http_support.py): verbose logs and error messages now redact authorization/signing headers, OAuth tokens and client secrets in request/response bodies, presigned-URL query credentials, and -E environment-variable values.

Two behavior changes outside the Connect Cloud path (see CHANGELOG for details):

  • deploy manifest/deploy bundle no longer overwrite an existing content title when -t is not given, matching the other deploy commands.
  • Deploying with a nickname no longer conflicts with SHINYAPPS_* environment variables that happen to be set, and deploy pyproject no longer uses the nickname as a title override.

Automated Tests

  • tests/test_connect_cloud.py (new, ~2800 lines): device-code and client-credentials auth flows, token refresh and write-back, rsconnect add verification, the full deploy/publish/poll flow, error paths and publish-log output, and environment selection — all HTTP mocked with httpretty.
  • tests/test_http_support.py: credential redaction coverage.
  • Updates to test_metadata.py, test_oauth.py, test_validation.py, and others for the credential store and validation changes.

Directions for Reviewers

  • Register an account: rsconnect add -n cloud --connect-cloud -A <account> (opens a browser for device login; use --client-id/--client-secret for a service account).
  • Deploy something: rsconnect deploy shiny -n cloud ./app, then redeploy to exercise the update path.
  • The full manual test plan was run against staging (all eight supported content types published live) and against production (device login, deploy, and the 401 → refresh → retry path with refresh-token rotation).

Checklist

  • I have updated CHANGELOG.md to cover notable changes.
  • I have updated all related GitHub issues to reflect their current state.
  • I have run the rsconnect-python-tests-at-night workflow in Connect against this feature branch.

Adds Posit Connect Cloud as a deployment target alongside Posit Connect
and shinyapps.io, mirroring the R rsconnect package's support:

- Select the target with --connect-cloud or -s connect.posit.cloud.
- Authenticate with an interactive OAuth device-code login or a service
  account client ID/secret (client credentials grant), with automatic
  token refresh and write-back to the credential store.
- Register credentials with `rsconnect add`, verifying the account
  exists and grants the content:create permission before storing.
- Deploy through the Connect Cloud revision model: create or update
  content, upload the bundle to a presigned URL, publish, poll the
  revision, and print the publish log from the logs service on failure.
- Record deployments locally before publishing, since Connect Cloud
  cannot look content up by name.
- Support the production, staging, and development environments via
  CONNECT_CLOUD_ENVIRONMENT, pinned to the saved server's URL.

Fixes #817
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://posit-dev.github.io/rsconnect-python/pr-preview/pr-837/

Built to branch gh-pages at 2026-08-13 18:20 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

`cast(dict[str, Any], ...)` evaluates its first argument at runtime, so
`from __future__ import annotations` does not cover it and `dict[...]`
raises TypeError on Python 3.8. Describe the response with a TypedDict
instead, matching the other ConnectCloudClient methods.
@github-actions

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
8479 7189 85% 0% 🟢

New Files

File Coverage Status
rsconnect/connect_cloud.py 100% 🟢
TOTAL 100% 🟢

Modified Files

File Coverage Status
rsconnect/actions.py 62% 🟢
rsconnect/api.py 84% 🟢
rsconnect/certificates.py 100% 🟢
rsconnect/http_support.py 84% 🟢
rsconnect/json_web_token.py 97% 🟢
rsconnect/main.py 83% 🟢
rsconnect/metadata.py 88% 🟢
rsconnect/models.py 93% 🟢
rsconnect/oauth.py 79% 🟢
rsconnect/shiny_express.py 93% 🟢
rsconnect/validation.py 85% 🟢
TOTAL 86% 🟢

updated for commit: 58e096c by action🐍

@samperman
samperman requested a review from vrsarah August 13, 2026 18:40
@nealrichardson

Copy link
Copy Markdown
Contributor

deploy manifest/deploy bundle no longer overwrite an existing content title when -t is not given, matching the other deploy commands.

I believe this is also #836, @tdstein PTAL

@nealrichardson
nealrichardson requested a review from a team August 13, 2026 18:53
Comment thread rsconnect/http_support.py

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the changes in this file are added to support redaction of potential secrets from verbose/debug output. this isn't specific to the Connect Cloud support, but adding that support included more secrets (OAuth
tokens, client secrets, deploy-time secrets in bodies, presigned upload URLs whose query string is itself basically a credential) that I wanted to keep out of any logs.

The redaction. covers all deploy targets: Authorization headers, cookies, the bootstrap response's freshly minted API key, and shinyapps' X-Auth-Token/X-Auth-Signature. handle_bad_response redacts URIs quoted in error messages. output_params now also masks token, secret, client_secret, and -E env var values (previously only api_key)

Comment thread rsconnect/http_support.py
self.response_body = body
# None when the request failed before a response arrived (exception set),
# so status checks on a connection failure do not raise AttributeError.
self.status: int | None = None

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously these attributes were only set when a response actually arrived, so code that checked response.status after a connection failure could hit AttributeError instead of a real error message. Downstream checks were updated for the Optional type: RSConnectClient.is_failed_response, the SPCS token-exchange handler (which now also surfaces response.exception properly), and a simplification in json_web_token.py (no need for hasattr)

Comment thread rsconnect/main.py
return wrapper


# Parameters whose values are credentials and must not reach the verbose log.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment at top of file on http_support.py

Comment thread rsconnect/main.py
"--cacert",
"-c",
envvar="CONNECT_CA_CERTIFICATE",
type=click.Path(exists=True, file_okay=True, dir_okay=False),

@samperman samperman Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--cacert existence check moved from parse time to read time (and only when its relevant... ie, a Connect Cloud deploy doesn't care)

Comment thread rsconnect/validation.py
# executor re-raises the all-or-nothing error after resolution when the
# target turns out not to be Connect Cloud. A token or secret is unambiguous
# shinyapps intent, so those still fail fast here.
lone_account_with_default = bool(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lone -A/--account with a default server is no longer rejected immediately

Comment thread rsconnect/main.py
# Cloud content after the nickname.
target = resolve_pyproject_deploy_target(
pyproject_path, requirements_file=requirements_file, title_override=title or name
pyproject_path, requirements_file=requirements_file, title_override=title

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before, redeploying with -n mynickname silently renamed the content after the nickname. Now only an explicit -t/--title overrides the pyproject title. This applies to Connect deploys too.

Comment thread rsconnect/api.py
self.write_deployed_info()
return self

def record_server_key(self) -> str:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppStore records were keyed by server URL; they're now keyed by record_server_key(), which is still exactly the URL for Connect/shinyapps/SPCS (no migration or behavior change) and url#account only for Connect Cloud, where every account shares one API URL. save_deployed_info was split so a write_deployed_info helper can record the content id early on Cloud; the Connect path is unchanged.

Comment thread rsconnect/main.py
return _login_device(server, cid, metadata, insecure, ca_data)
# Asking for the device flow against Connect usually means there is no
# usable browser, so do not try to open one.
return _login_device(server, cid, metadata, insecure, ca_data, open_browser=False)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the device-code flow gained an open_browser option. This passes False to preserve the existing Connect behavior.

Comment thread rsconnect/actions.py
quarto: QuartoInspectResultQuarto
engines: list[str]
config: NotRequired[QuartoInspectResultConfig]
files: NotRequired[QuartoInspectResultFiles]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set in all quarto inspections, but only needed for the Connect Cloud path (to pick primary file)

Comment thread rsconnect/main.py
),
no_args_is_help=True,
)
@cli_exception_handler

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors from add are now formatted like the deploy commands' instead of surfacing as raw tracebacks; this affects all add usage, not just Connect Cloud.

Comment thread rsconnect/validation.py
def _get_present_options(
options: dict[str, Optional[Any]],
ctx: Optional[click.Context],
ignore_sources: tuple[str, ...] = (),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a shared validation change. _get_present_options can now skip option values that came from environment variables. Previously an env-sourced value (e.g. SHINYAPPS_ACCOUNT exported for a different CI job) was indistinguishable from a typed option and triggered "conflicting options" errors. Connect Cloud made this worse, a leftover CONNECT_API_KEY or CONNECT_SERVER shouldn't fail a --connect-cloud deploy, so the conflict checks now treat only typed options as intent and ignore irrelevant env values. Applied consistently across all targets, so nickname deploys with leftover shinyapps exports no longer error either. Existing passing invocations are unaffected.

Comment thread rsconnect/api.py
target_url = url
if use_connect_cloud and not explicit_cloud_url:
target_url = connect_cloud.SERVER_NAME
# A nickname names a saved credential, so environment-sourced shinyapps

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread docs/CHANGELOG.md
title override. The title now comes from `-t/--title` or the pyproject
metadata, so redeploying with a nickname no longer renames existing content
after the nickname.
- Added support for Python 3.14. The test suite now runs on Python 3.14 in CI.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i put my changes in the some Unreleased block as these pre-existing unreleased lines. Not sure if these were actually still unreleased or not.

@mconflitti-pbc

Copy link
Copy Markdown
Contributor

@samperman A few behavioral/design questions and suggestions around target resolution and OAuth:

  • Could ConnectCloudClient share the existing bearer-token refresh machinery in RSConnectClient? Both implement 401 → refresh → persist rotated tokens → retry once, so a common helper with target-specific refresh/persistence callbacks could reduce drift.
  • Could Connect Cloud reuse the existing keyring-backed OAuth token storage instead of storing access and refresh tokens directly in servers.json? Service-account client secrets may need separate handling.
  • Is supporting multiple saved credentials for the same Connect Cloud server URL required? If not, the account-sensitive credential lookup and ambiguity handling could be simplified. A single credential may still publish to multiple accounts, so deployment records should remain account-specific.
  • --set-default already avoids repeatedly passing --connect-cloud, but it is global. Could redeployment infer the target from the existing application metadata when no target is supplied? The metadata already records the server key; for Cloud, the URL/account-ID composite could select the previous deployment and the unique saved credential.
  • Reactive refresh on 401 seems appropriate, and refresh-token rotation is handled. Refresh failures should be actionable, though: distinguish expired/revoked credentials, clear unusable stored tokens where appropriate, and tell the user to authenticate again instead of exposing only the original 401.

@samperman

Copy link
Copy Markdown
Collaborator Author

thanks for looking @mconflitti-pbc

i think the answer to most of your questions would be "yes" if we only allowed a single
Connect Cloud credential. That would work, but it prevents saving an interactive login and
a service account side by side, or a personal and a work login. i think multiple cloud credentials is valuable.

you're right that a single credential can publish to multiple accounts, though, which
means the account isn't really what identifies a credential. so i'd keep the saved account
as the default publish target, but switch credential disambiguation from account to
nickname: one saved credential -> use it (-A just picks where to publish), multiple ->
-n picks which credential. that removes the account-matching branches and their error
paths, and single-credential users never see any of it.

given that:

  • Could ConnectCloudClient share the existing bearer-token refresh machinery?
    • yes. the retry-once skeleton is shareable today, as you suggested. if we also do the keyring
      refactor, the load and persist callbacks collapse into shared code and only the
      token-minting step stays target-specific
  • Could Connect Cloud reuse the existing keyring-backed OAuth token storage?
    • yes. keyring entries are currently keyed by url; cloud entries would key by
      url + nickname. the key parameter is additive, so existing Connect logins are
      untouched. the servers.json fields stay as a fallback for keyring-less environments
      (CI, headless). service-account client secrets: in CI they come from env vars and are
      never stored; when saved via rsconnect add they can go in the keyring the same way.
  • Is supporting multiple saved credentials for the same Connect Cloud server URL required?
    • in my opinion yes, for the multiple-identity case above, though with nickname
      disambiguation the extra machinery is invisible unless you actually have more than
      one. and agreed on deployment records: they're already account-specific (keyed by
      url + account id, with the account name as fallback for older records).
  • Could redeployment infer the target from the existing application metadata?
    • good idea.
  • Refresh failures should be actionable
    • good idea.

@samperman

Copy link
Copy Markdown
Collaborator Author

changing this to a draft while i work on some refactoring

@samperman
samperman marked this pull request as draft August 14, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for publishing to Posit Connect Cloud

3 participants