Skip to content

Commit 2dcff43

Browse files
edavidajaclaude
andcommitted
rsconnect deploy git
Adds support for creating git-backed deployments supersedes #501 Features: - New `rsconnect deploy git` command with --repository, --branch, and --subdirectory options - Comprehensive test coverage for CLI and API methods 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b28aa50 commit 2dcff43

6 files changed

Lines changed: 1094 additions & 5 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4747

4848
### Added
4949

50+
- Added `rsconnect deploy git` command to create a [git-backed deployment](https://docs.posit.co/connect/user/git-backed/).
51+
Use `--branch` to specify a branch (default: main) and `--subdirectory` to deploy content from a subdirectory.
5052
- `rsconnect content get-lockfile` command allows fetching a lockfile with the
5153
dependencies installed by connect to run the deployed content
5254
- `rsconnect content venv` command recreates a local python environment
@@ -59,7 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5961
Posit Connect 2025.12.0 or later. Use `--metadata key=value` to provide additional metadata
6062
or override detected values. Use `--no-metadata` to disable automatic detection. (#736)
6163

62-
6364
## [1.28.2] - 2025-12-05
6465

6566
### Fixed

rsconnect/api.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
OAuthIntegrationUpdate,
8888
OAuthTemplate,
8989
PyInfo,
90+
RepositoryBundleOutput,
91+
RepositoryInfo,
9092
ServerSettings,
9193
TaskStatusV1,
9294
UserRecord,
@@ -729,6 +731,225 @@ def content_deploy(
729731
response = self._server.handle_bad_response(response)
730732
return response
731733

734+
def get_repository(self, content_guid: str) -> Optional[RepositoryInfo]:
735+
"""Get git repository configuration for a content item.
736+
737+
GET /v1/content/{guid}/repository
738+
739+
:param content_guid: The GUID of the content item
740+
:return: Repository configuration if git-managed, None otherwise
741+
"""
742+
response = self.get("v1/content/%s/repository" % content_guid)
743+
if isinstance(response, HTTPResponse):
744+
# 404 means not git-managed, which is not an error
745+
if response.status == 404:
746+
return None
747+
self._server.handle_bad_response(response)
748+
return cast(RepositoryInfo, response)
749+
750+
def set_repository(
751+
self,
752+
content_guid: str,
753+
repository: str,
754+
branch: str = "main",
755+
directory: str = ".",
756+
polling: bool = True,
757+
) -> RepositoryInfo:
758+
"""Create or overwrite git repository configuration for a content item.
759+
760+
PUT /v1/content/{guid}/repository
761+
762+
:param content_guid: The GUID of the content item
763+
:param repository: URL of the git repository (https:// only)
764+
:param branch: Branch to deploy from (default: main)
765+
:param directory: Directory containing manifest.json (default: .)
766+
:param polling: Enable auto-redeploy when commits are pushed (default: True)
767+
:return: The repository configuration
768+
"""
769+
body = {
770+
"repository": repository,
771+
"branch": branch,
772+
"directory": directory,
773+
"polling": polling,
774+
}
775+
response = cast(
776+
Union[RepositoryInfo, HTTPResponse],
777+
self.put("v1/content/%s/repository" % content_guid, body=body),
778+
)
779+
response = self._server.handle_bad_response(response)
780+
return response
781+
782+
def update_repository(
783+
self,
784+
content_guid: str,
785+
repository: Optional[str] = None,
786+
branch: Optional[str] = None,
787+
directory: Optional[str] = None,
788+
polling: Optional[bool] = None,
789+
) -> RepositoryInfo:
790+
"""Partially update git repository configuration for a content item.
791+
792+
PATCH /v1/content/{guid}/repository
793+
794+
Only fields that are provided will be updated.
795+
796+
:param content_guid: The GUID of the content item
797+
:param repository: URL of the git repository (https:// only)
798+
:param branch: Branch to deploy from
799+
:param directory: Directory containing manifest.json
800+
:param polling: Enable auto-redeploy when commits are pushed
801+
:return: The updated repository configuration
802+
"""
803+
body: dict[str, str | bool] = {}
804+
if repository is not None:
805+
body["repository"] = repository
806+
if branch is not None:
807+
body["branch"] = branch
808+
if directory is not None:
809+
body["directory"] = directory
810+
if polling is not None:
811+
body["polling"] = polling
812+
813+
response = cast(
814+
Union[RepositoryInfo, HTTPResponse],
815+
self.patch("v1/content/%s/repository" % content_guid, body=body),
816+
)
817+
response = self._server.handle_bad_response(response)
818+
return response
819+
820+
def delete_repository(self, content_guid: str) -> None:
821+
"""Remove git repository configuration from a content item.
822+
823+
DELETE /v1/content/{guid}/repository
824+
825+
:param content_guid: The GUID of the content item
826+
"""
827+
response = self.delete("v1/content/%s/repository" % content_guid)
828+
if isinstance(response, HTTPResponse):
829+
self._server.handle_bad_response(response, is_httpresponse=True)
830+
831+
def create_bundle_from_repository(
832+
self,
833+
content_guid: str,
834+
repository: Optional[str] = None,
835+
ref: Optional[str] = None,
836+
directory: Optional[str] = None,
837+
) -> RepositoryBundleOutput:
838+
"""Create a bundle from a git repository location.
839+
840+
POST /v1/content/{guid}/repository/bundle
841+
842+
This triggers Connect to clone the repository and create a bundle.
843+
If the content item has existing git configuration, those values are used
844+
as defaults; provided parameters will override them.
845+
846+
:param content_guid: The GUID of the content item
847+
:param repository: URL of the git repository (uses existing config if not provided)
848+
:param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
849+
:param directory: Directory containing manifest.json (uses existing config if not provided)
850+
:return: Bundle creation result with bundle_id and task_id
851+
"""
852+
body: dict[str, str] = {}
853+
if repository is not None:
854+
body["repository"] = repository
855+
if ref is not None:
856+
body["ref"] = ref
857+
if directory is not None:
858+
body["directory"] = directory
859+
860+
response = cast(
861+
Union[RepositoryBundleOutput, HTTPResponse],
862+
self.post("v1/content/%s/repository/bundle" % content_guid, body=body),
863+
)
864+
response = self._server.handle_bad_response(response)
865+
return response
866+
867+
def deploy_git(
868+
self,
869+
app_id: Optional[str],
870+
name: str,
871+
repository: str,
872+
branch: str,
873+
subdirectory: str,
874+
title: Optional[str],
875+
env_vars: Optional[dict[str, str]],
876+
polling: bool = True,
877+
) -> RSConnectClientDeployResult:
878+
"""Deploy content from a git repository.
879+
880+
Creates or updates a git-backed content item in Posit Connect. Connect will clone
881+
the repository and automatically redeploy when commits are pushed (if polling is enabled).
882+
883+
:param app_id: Existing content ID/GUID to update, or None to create new content
884+
:param name: Name for the content item (used if creating new)
885+
:param repository: URL of the git repository (https:// only)
886+
:param branch: Branch to deploy from
887+
:param subdirectory: Subdirectory containing manifest.json
888+
:param title: Title for the content
889+
:param env_vars: Environment variables to set
890+
:param polling: Enable auto-redeploy when commits are pushed (default: True)
891+
:return: Deployment result with task_id, app info, etc.
892+
"""
893+
# Create or get existing content
894+
if app_id is None:
895+
app = self.content_create(name)
896+
else:
897+
try:
898+
app = self.get_content_by_id(app_id)
899+
except RSConnectException as e:
900+
raise RSConnectException(
901+
f"{e} Try setting the --new flag or omit --app-id to create new content."
902+
) from e
903+
904+
app_guid = app["guid"]
905+
906+
# Map subdirectory to directory (API uses "directory" field)
907+
directory = subdirectory if subdirectory else "."
908+
909+
# Check if content already has git configuration
910+
existing_repo = self.get_repository(app_guid)
911+
912+
if existing_repo:
913+
# Update existing git configuration using PATCH
914+
self.update_repository(
915+
app_guid,
916+
repository=repository,
917+
branch=branch,
918+
directory=directory,
919+
polling=polling,
920+
)
921+
else:
922+
# Create new git configuration using PUT
923+
self.set_repository(
924+
app_guid,
925+
repository=repository,
926+
branch=branch,
927+
directory=directory,
928+
polling=polling,
929+
)
930+
931+
# Update title if provided (and different from current)
932+
if title and app.get("title") != title:
933+
self.patch("v1/content/%s" % app_guid, body={"title": title})
934+
935+
# Set environment variables
936+
if env_vars:
937+
result = self.add_environment_vars(app_guid, list(env_vars.items()))
938+
self._server.handle_bad_response(result)
939+
940+
# Trigger deployment (bundle_id=None uses the latest bundle from git clone)
941+
task = self.content_deploy(app_guid, bundle_id=None)
942+
943+
return RSConnectClientDeployResult(
944+
app_id=str(app["id"]),
945+
app_guid=app_guid,
946+
app_url=app["content_url"],
947+
task_id=task["task_id"],
948+
title=title or app.get("title"),
949+
dashboard_url=app["dashboard_url"],
950+
draft_url=None,
951+
)
952+
732953
def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]:
733954
response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime"))
734955
response = self._server.handle_bad_response(response)
@@ -1002,6 +1223,10 @@ def __init__(
10021223
disable_env_management: Optional[bool] = None,
10031224
env_vars: Optional[dict[str, str]] = None,
10041225
metadata: Optional[dict[str, str]] = None,
1226+
repository: Optional[str] = None,
1227+
branch: Optional[str] = None,
1228+
subdirectory: Optional[str] = None,
1229+
polling: bool = True,
10051230
) -> None:
10061231
self.remote_server: TargetableServer
10071232
self.client: RSConnectClient | PositClient
@@ -1023,6 +1248,12 @@ def __init__(
10231248
self.title_is_default: bool = not title
10241249
self.deployment_name: str | None = None
10251250

1251+
# Git deployment parameters
1252+
self.repository: str | None = repository
1253+
self.branch: str | None = branch
1254+
self.subdirectory: str | None = subdirectory
1255+
self.polling: bool = polling
1256+
10261257
self.bundle: IO[bytes] | None = None
10271258
self.deployed_info: RSConnectClientDeployResult | None = None
10281259

@@ -1065,6 +1296,10 @@ def fromConnectServer(
10651296
disable_env_management: Optional[bool] = None,
10661297
env_vars: Optional[dict[str, str]] = None,
10671298
metadata: Optional[dict[str, str]] = None,
1299+
repository: Optional[str] = None,
1300+
branch: Optional[str] = None,
1301+
subdirectory: Optional[str] = None,
1302+
polling: bool = True,
10681303
):
10691304
return cls(
10701305
ctx=ctx,
@@ -1088,6 +1323,10 @@ def fromConnectServer(
10881323
disable_env_management=disable_env_management,
10891324
env_vars=env_vars,
10901325
metadata=metadata,
1326+
repository=repository,
1327+
branch=branch,
1328+
subdirectory=subdirectory,
1329+
polling=polling,
10911330
)
10921331

10931332
def output_overlap_header(self, previous: bool) -> bool:
@@ -1409,6 +1648,49 @@ def deploy_bundle(self, activate: bool = True):
14091648
)
14101649
return self
14111650

1651+
@cls_logged("Creating git-backed deployment ...")
1652+
def deploy_git(self):
1653+
"""Deploy content from a remote git repository.
1654+
1655+
Creates a git-backed content item in Posit Connect. Connect will clone
1656+
the repository and automatically redeploy when commits are pushed.
1657+
"""
1658+
if not isinstance(self.client, RSConnectClient):
1659+
raise RSConnectException(
1660+
"Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1661+
)
1662+
1663+
if not self.repository:
1664+
raise RSConnectException("Repository URL is required for git deployment.")
1665+
1666+
# Generate a valid deployment name from the title
1667+
# This sanitizes characters like "/" that aren't allowed in names
1668+
force_unique_name = self.app_id is None
1669+
deployment_name = self.make_deployment_name(self.title, force_unique_name)
1670+
1671+
try:
1672+
result = self.client.deploy_git(
1673+
app_id=self.app_id,
1674+
name=deployment_name,
1675+
repository=self.repository,
1676+
branch=self.branch or "main",
1677+
subdirectory=self.subdirectory or "",
1678+
title=self.title,
1679+
env_vars=self.env_vars,
1680+
polling=self.polling,
1681+
)
1682+
except RSConnectException as e:
1683+
# Check for 404 on /repo endpoint (git not enabled)
1684+
if "404" in str(e) and "repo" in str(e).lower():
1685+
raise RSConnectException(
1686+
"Git-backed deployment is not enabled on this Connect server. "
1687+
"Contact your administrator to enable Git support."
1688+
) from e
1689+
raise
1690+
1691+
self.deployed_info = result
1692+
return self
1693+
14121694
def emit_task_log(
14131695
self,
14141696
log_callback: logging.Logger = connect_logger,

0 commit comments

Comments
 (0)