Skip to content

Commit ce27e87

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 0721cce commit ce27e87

6 files changed

Lines changed: 1098 additions & 6 deletions

File tree

docs/CHANGELOG.md

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

2727
### Added
2828

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

41-
4243
## [1.28.2] - 2025-12-05
4344

4445
### Fixed

rsconnect/api.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
EnvironmentV1,
8484
ListEntryOutputDTO,
8585
PyInfo,
86+
RepositoryBundleOutput,
87+
RepositoryInfo,
8688
ServerSettings,
8789
TaskStatusV1,
8890
UserRecord,
@@ -725,6 +727,225 @@ def content_deploy(
725727
response = self._server.handle_bad_response(response)
726728
return response
727729

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

1213+
# Git deployment parameters
1214+
self.repository: str | None = repository
1215+
self.branch: str | None = branch
1216+
self.subdirectory: str | None = subdirectory
1217+
self.polling: bool = polling
1218+
9881219
self.bundle: IO[bytes] | None = None
9891220
self.deployed_info: RSConnectClientDeployResult | None = None
9901221

@@ -1027,6 +1258,10 @@ def fromConnectServer(
10271258
disable_env_management: Optional[bool] = None,
10281259
env_vars: Optional[dict[str, str]] = None,
10291260
metadata: Optional[dict[str, str]] = None,
1261+
repository: Optional[str] = None,
1262+
branch: Optional[str] = None,
1263+
subdirectory: Optional[str] = None,
1264+
polling: bool = True,
10301265
):
10311266
return cls(
10321267
ctx=ctx,
@@ -1050,6 +1285,10 @@ def fromConnectServer(
10501285
disable_env_management=disable_env_management,
10511286
env_vars=env_vars,
10521287
metadata=metadata,
1288+
repository=repository,
1289+
branch=branch,
1290+
subdirectory=subdirectory,
1291+
polling=polling,
10531292
)
10541293

10551294
def output_overlap_header(self, previous: bool) -> bool:
@@ -1371,6 +1610,49 @@ def deploy_bundle(self, activate: bool = True):
13711610
)
13721611
return self
13731612

1613+
@cls_logged("Creating git-backed deployment ...")
1614+
def deploy_git(self):
1615+
"""Deploy content from a remote git repository.
1616+
1617+
Creates a git-backed content item in Posit Connect. Connect will clone
1618+
the repository and automatically redeploy when commits are pushed.
1619+
"""
1620+
if not isinstance(self.client, RSConnectClient):
1621+
raise RSConnectException(
1622+
"Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1623+
)
1624+
1625+
if not self.repository:
1626+
raise RSConnectException("Repository URL is required for git deployment.")
1627+
1628+
# Generate a valid deployment name from the title
1629+
# This sanitizes characters like "/" that aren't allowed in names
1630+
force_unique_name = self.app_id is None
1631+
deployment_name = self.make_deployment_name(self.title, force_unique_name)
1632+
1633+
try:
1634+
result = self.client.deploy_git(
1635+
app_id=self.app_id,
1636+
name=deployment_name,
1637+
repository=self.repository,
1638+
branch=self.branch or "main",
1639+
subdirectory=self.subdirectory or "",
1640+
title=self.title,
1641+
env_vars=self.env_vars,
1642+
polling=self.polling,
1643+
)
1644+
except RSConnectException as e:
1645+
# Check for 404 on /repo endpoint (git not enabled)
1646+
if "404" in str(e) and "repo" in str(e).lower():
1647+
raise RSConnectException(
1648+
"Git-backed deployment is not enabled on this Connect server. "
1649+
"Contact your administrator to enable Git support."
1650+
) from e
1651+
raise
1652+
1653+
self.deployed_info = result
1654+
return self
1655+
13741656
def emit_task_log(
13751657
self,
13761658
log_callback: logging.Logger = connect_logger,

0 commit comments

Comments
 (0)