Skip to content

Commit f21d9b5

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 03c506f commit f21d9b5

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
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525

2626
### Added
2727

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

40-
4142
## [1.28.2] - 2025-12-05
4243

4344
### Fixed

rsconnect/api.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
DeleteOutputDTO,
7979
ListEntryOutputDTO,
8080
PyInfo,
81+
RepositoryBundleOutput,
82+
RepositoryInfo,
8183
ServerSettings,
8284
TaskStatusV1,
8385
UserRecord,
@@ -720,6 +722,225 @@ def content_deploy(
720722
response = self._server.handle_bad_response(response)
721723
return response
722724

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

1161+
# Git deployment parameters
1162+
self.repository: str | None = repository
1163+
self.branch: str | None = branch
1164+
self.subdirectory: str | None = subdirectory
1165+
self.polling: bool = polling
1166+
9361167
self.bundle: IO[bytes] | None = None
9371168
self.deployed_info: RSConnectClientDeployResult | None = None
9381169

@@ -975,6 +1206,10 @@ def fromConnectServer(
9751206
disable_env_management: Optional[bool] = None,
9761207
env_vars: Optional[dict[str, str]] = None,
9771208
metadata: Optional[dict[str, str]] = None,
1209+
repository: Optional[str] = None,
1210+
branch: Optional[str] = None,
1211+
subdirectory: Optional[str] = None,
1212+
polling: bool = True,
9781213
):
9791214
return cls(
9801215
ctx=ctx,
@@ -998,6 +1233,10 @@ def fromConnectServer(
9981233
disable_env_management=disable_env_management,
9991234
env_vars=env_vars,
10001235
metadata=metadata,
1236+
repository=repository,
1237+
branch=branch,
1238+
subdirectory=subdirectory,
1239+
polling=polling,
10011240
)
10021241

10031242
def output_overlap_header(self, previous: bool) -> bool:
@@ -1319,6 +1558,49 @@ def deploy_bundle(self, activate: bool = True):
13191558
)
13201559
return self
13211560

1561+
@cls_logged("Creating git-backed deployment ...")
1562+
def deploy_git(self):
1563+
"""Deploy content from a remote git repository.
1564+
1565+
Creates a git-backed content item in Posit Connect. Connect will clone
1566+
the repository and automatically redeploy when commits are pushed.
1567+
"""
1568+
if not isinstance(self.client, RSConnectClient):
1569+
raise RSConnectException(
1570+
"Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1571+
)
1572+
1573+
if not self.repository:
1574+
raise RSConnectException("Repository URL is required for git deployment.")
1575+
1576+
# Generate a valid deployment name from the title
1577+
# This sanitizes characters like "/" that aren't allowed in names
1578+
force_unique_name = self.app_id is None
1579+
deployment_name = self.make_deployment_name(self.title, force_unique_name)
1580+
1581+
try:
1582+
result = self.client.deploy_git(
1583+
app_id=self.app_id,
1584+
name=deployment_name,
1585+
repository=self.repository,
1586+
branch=self.branch or "main",
1587+
subdirectory=self.subdirectory or "",
1588+
title=self.title,
1589+
env_vars=self.env_vars,
1590+
polling=self.polling,
1591+
)
1592+
except RSConnectException as e:
1593+
# Check for 404 on /repo endpoint (git not enabled)
1594+
if "404" in str(e) and "repo" in str(e).lower():
1595+
raise RSConnectException(
1596+
"Git-backed deployment is not enabled on this Connect server. "
1597+
"Contact your administrator to enable Git support."
1598+
) from e
1599+
raise
1600+
1601+
self.deployed_info = result
1602+
return self
1603+
13221604
def emit_task_log(
13231605
self,
13241606
log_callback: logging.Logger = connect_logger,

0 commit comments

Comments
 (0)