From 23602e078881782f59783bc2928d12ebfcca888a Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Thu, 29 Oct 2026 10:51:31 -0700 Subject: [PATCH 1/2] Keep Connect Cloud secrets that -E does not name on redeploy --- docs/CHANGELOG.md | 3 +++ rsconnect/api.py | 38 +++++++++++++++++++++++++++---------- tests/test_connect_cloud.py | 31 ++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ba110352..cc536486 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Deploying to Posit Connect Cloud with `-E`/`--environment` no longer deletes the + content's other secrets, such as secrets added in the Connect Cloud web interface. + The named variables are set and the other secrets are kept. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API requests with your login. `--refresh` gets a new token after the server rejects diff --git a/rsconnect/api.py b/rsconnect/api.py index 36315c01..93f1ac98 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3298,11 +3298,18 @@ class ConnectCloudRevision(TypedDict): publish_log_channel: NotRequired[Optional[str]] +class ConnectCloudSecret(TypedDict): + name: str + # The API never returns a secret's value. On update, None keeps the current value. + value: Optional[str] + + class ConnectCloudContent(TypedDict): id: str account_id: NotRequired[str] title: NotRequired[str] state: NotRequired[str] + secrets: NotRequired[list[ConnectCloudSecret]] next_revision: NotRequired[Optional[ConnectCloudRevision]] current_revision: NotRequired[Optional[ConnectCloudRevision]] @@ -3684,7 +3691,7 @@ def create_content( content_type: str, app_mode: str, primary_file: str, - secrets: Optional[list[dict[str, str]]] = None, + secrets: Optional[list[ConnectCloudSecret]] = None, access: Optional[str] = None, python_version: Optional[str] = None, ) -> ConnectCloudContent: @@ -3722,7 +3729,7 @@ def update_content( primary_file: str, app_mode: str, content_type: str, - secrets: Optional[list[dict[str, str]]] = None, + secrets: Optional[list[ConnectCloudSecret]] = None, new_bundle: bool = True, title: Optional[str] = None, access: Optional[str] = None, @@ -3735,11 +3742,12 @@ def update_content( changes what kind of content this is (--app-id pointing at content of another type) does not keep the old content type. - `secrets` replaces the content's whole set, and `title` overwrites the - stored one, so both are omitted from the request entirely when None: a - deploy without -E/-t must leave the existing values alone. `access` (the - content's visibility) is omitted the same way, so a redeploy without - -V keeps whatever visibility the content already has. + `secrets` replaces the content's whole set (a secret left out is + deleted, and one with a None value keeps its current value), and `title` + overwrites the stored one, so both are omitted from the request entirely + when None: a deploy without -E/-t must leave the existing values alone. + `access` (the content's visibility) is omitted the same way, so a + redeploy without -V keeps whatever visibility the content already has. `python_version` is omitted when None for the same reason: a redeploy of content whose bundle carries no Python version keeps the version already @@ -3970,9 +3978,10 @@ def prepare_deploy( ) # None (no -E given) means "leave the server's secrets alone"; it reaches - # update_content as None and is omitted from the PATCH. A non-empty -E set - # replaces the whole collection, matching the R client. - secrets = [{"name": name, "value": value} for name, value in env_vars.items()] if env_vars else None + # update_content as None and is omitted from the PATCH. + secrets: Optional[list[ConnectCloudSecret]] = None + if env_vars: + secrets = [ConnectCloudSecret(name=name, value=value) for name, value in env_vars.items()] content: Optional[ConnectCloudContent] = None if app_id: @@ -4019,6 +4028,15 @@ def prepare_deploy( access=visibility, ) else: + # The PATCH replaces the whole secret set, so send the content's other + # secrets with a None value to keep them, including ones added in the + # Connect Cloud UI. + if secrets is not None and env_vars: + secrets += [ + ConnectCloudSecret(name=secret["name"], value=None) + for secret in content.get("secrets") or [] + if secret["name"] not in env_vars + ] # Existing content: push secrets and entrypoint, and ask for a new # revision to upload into. Only freshly *created* content (above) can # skip this. In particular content whose first publish failed has no diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 8d140621..ccea858b 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -2528,6 +2528,37 @@ def test_prepare_deploy_without_env_vars_does_not_touch_secrets(self): self._prepare_deploy(app_id="c1", env_vars={}) self.assertIsNone(self.client.update_content.call_args.kwargs["secrets"]) + def test_prepare_deploy_with_env_vars_keeps_other_secrets(self): + # The secrets list replaces every secret on the content, so secrets that + # -E does not name (for example ones added in the Connect Cloud UI) must + # be sent with a null value to keep them. + self.client.get_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "current_revision": {"id": "r0"}, + "secrets": [{"name": "UI_KEY", "value": None}, {"name": "FOO", "value": None}], + } + self._prepare_deploy(app_id="c1", env_vars={"FOO": "bar"}) + self.assertEqual( + self.client.update_content.call_args.kwargs["secrets"], + [{"name": "FOO", "value": "bar"}, {"name": "UI_KEY", "value": None}], + ) + + def test_prepare_deploy_with_env_vars_and_no_other_secrets(self): + for secrets in (None, []): + with self.subTest(secrets=secrets): + self.client.get_content.return_value = { + "id": "c1", + "account_id": "acct-1", + "current_revision": {"id": "r0"}, + "secrets": secrets, + } + self._prepare_deploy(app_id="c1", env_vars={"FOO": "bar"}) + self.assertEqual( + self.client.update_content.call_args.kwargs["secrets"], + [{"name": "FOO", "value": "bar"}], + ) + def test_prepare_deploy_updates_existing_content(self): result = self._prepare_deploy(app_id="c1") From 4dca9ffc2d77a4a62fe942d7f9079cffc62dcfe2 Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Thu, 29 Oct 2026 11:42:12 -0700 Subject: [PATCH 2/2] Say UI instead of web interface in changelog --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cc536486..078f9c24 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - Deploying to Posit Connect Cloud with `-E`/`--environment` no longer deletes the - content's other secrets, such as secrets added in the Connect Cloud web interface. + content's other secrets, such as secrets added in the Connect Cloud UI. The named variables are set and the other secrets are kept. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API