Skip to content

Commit 7f6e383

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 4c32231 commit 7f6e383

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

1010
### Added
1111

12+
- Added `rsconnect deploy git` command to create a [git-backed deployment](https://docs.posit.co/connect/user/git-backed/).
13+
Use `--branch` to specify a branch (default: main) and `--subdirectory` to deploy content from a subdirectory.
1214
- `rsconnect content get-lockfile` command allows fetching a lockfile with the
1315
dependencies installed by connect to run the deployed content
1416
- `rsconnect content venv` command recreates a local python environment
@@ -22,7 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2224
or override detected values. Use `--no-metadata` to disable automatic detection. (#736)
2325
supply an explicit requirements file instead of detecting the environment.
2426

25-
2627
## [1.28.2] - 2025-12-05
2728

2829
### 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,
@@ -592,6 +594,225 @@ def content_deploy(
592594
response = self._server.handle_bad_response(response)
593595
return response
594596

597+
def get_repository(self, content_guid: str) -> Optional[RepositoryInfo]:
598+
"""Get git repository configuration for a content item.
599+
600+
GET /v1/content/{guid}/repository
601+
602+
:param content_guid: The GUID of the content item
603+
:return: Repository configuration if git-managed, None otherwise
604+
"""
605+
response = self.get("v1/content/%s/repository" % content_guid)
606+
if isinstance(response, HTTPResponse):
607+
# 404 means not git-managed, which is not an error
608+
if response.status == 404:
609+
return None
610+
self._server.handle_bad_response(response)
611+
return cast(RepositoryInfo, response)
612+
613+
def set_repository(
614+
self,
615+
content_guid: str,
616+
repository: str,
617+
branch: str = "main",
618+
directory: str = ".",
619+
polling: bool = True,
620+
) -> RepositoryInfo:
621+
"""Create or overwrite git repository configuration for a content item.
622+
623+
PUT /v1/content/{guid}/repository
624+
625+
:param content_guid: The GUID of the content item
626+
:param repository: URL of the git repository (https:// only)
627+
:param branch: Branch to deploy from (default: main)
628+
:param directory: Directory containing manifest.json (default: .)
629+
:param polling: Enable auto-redeploy when commits are pushed (default: True)
630+
:return: The repository configuration
631+
"""
632+
body = {
633+
"repository": repository,
634+
"branch": branch,
635+
"directory": directory,
636+
"polling": polling,
637+
}
638+
response = cast(
639+
Union[RepositoryInfo, HTTPResponse],
640+
self.put("v1/content/%s/repository" % content_guid, body=body),
641+
)
642+
response = self._server.handle_bad_response(response)
643+
return response
644+
645+
def update_repository(
646+
self,
647+
content_guid: str,
648+
repository: Optional[str] = None,
649+
branch: Optional[str] = None,
650+
directory: Optional[str] = None,
651+
polling: Optional[bool] = None,
652+
) -> RepositoryInfo:
653+
"""Partially update git repository configuration for a content item.
654+
655+
PATCH /v1/content/{guid}/repository
656+
657+
Only fields that are provided will be updated.
658+
659+
:param content_guid: The GUID of the content item
660+
:param repository: URL of the git repository (https:// only)
661+
:param branch: Branch to deploy from
662+
:param directory: Directory containing manifest.json
663+
:param polling: Enable auto-redeploy when commits are pushed
664+
:return: The updated repository configuration
665+
"""
666+
body: dict[str, str | bool] = {}
667+
if repository is not None:
668+
body["repository"] = repository
669+
if branch is not None:
670+
body["branch"] = branch
671+
if directory is not None:
672+
body["directory"] = directory
673+
if polling is not None:
674+
body["polling"] = polling
675+
676+
response = cast(
677+
Union[RepositoryInfo, HTTPResponse],
678+
self.patch("v1/content/%s/repository" % content_guid, body=body),
679+
)
680+
response = self._server.handle_bad_response(response)
681+
return response
682+
683+
def delete_repository(self, content_guid: str) -> None:
684+
"""Remove git repository configuration from a content item.
685+
686+
DELETE /v1/content/{guid}/repository
687+
688+
:param content_guid: The GUID of the content item
689+
"""
690+
response = self.delete("v1/content/%s/repository" % content_guid)
691+
if isinstance(response, HTTPResponse):
692+
self._server.handle_bad_response(response, is_httpresponse=True)
693+
694+
def create_bundle_from_repository(
695+
self,
696+
content_guid: str,
697+
repository: Optional[str] = None,
698+
ref: Optional[str] = None,
699+
directory: Optional[str] = None,
700+
) -> RepositoryBundleOutput:
701+
"""Create a bundle from a git repository location.
702+
703+
POST /v1/content/{guid}/repository/bundle
704+
705+
This triggers Connect to clone the repository and create a bundle.
706+
If the content item has existing git configuration, those values are used
707+
as defaults; provided parameters will override them.
708+
709+
:param content_guid: The GUID of the content item
710+
:param repository: URL of the git repository (uses existing config if not provided)
711+
:param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
712+
:param directory: Directory containing manifest.json (uses existing config if not provided)
713+
:return: Bundle creation result with bundle_id and task_id
714+
"""
715+
body: dict[str, str] = {}
716+
if repository is not None:
717+
body["repository"] = repository
718+
if ref is not None:
719+
body["ref"] = ref
720+
if directory is not None:
721+
body["directory"] = directory
722+
723+
response = cast(
724+
Union[RepositoryBundleOutput, HTTPResponse],
725+
self.post("v1/content/%s/repository/bundle" % content_guid, body=body),
726+
)
727+
response = self._server.handle_bad_response(response)
728+
return response
729+
730+
def deploy_git(
731+
self,
732+
app_id: Optional[str],
733+
name: str,
734+
repository: str,
735+
branch: str,
736+
subdirectory: str,
737+
title: Optional[str],
738+
env_vars: Optional[dict[str, str]],
739+
polling: bool = True,
740+
) -> RSConnectClientDeployResult:
741+
"""Deploy content from a git repository.
742+
743+
Creates or updates a git-backed content item in Posit Connect. Connect will clone
744+
the repository and automatically redeploy when commits are pushed (if polling is enabled).
745+
746+
:param app_id: Existing content ID/GUID to update, or None to create new content
747+
:param name: Name for the content item (used if creating new)
748+
:param repository: URL of the git repository (https:// only)
749+
:param branch: Branch to deploy from
750+
:param subdirectory: Subdirectory containing manifest.json
751+
:param title: Title for the content
752+
:param env_vars: Environment variables to set
753+
:param polling: Enable auto-redeploy when commits are pushed (default: True)
754+
:return: Deployment result with task_id, app info, etc.
755+
"""
756+
# Create or get existing content
757+
if app_id is None:
758+
app = self.content_create(name)
759+
else:
760+
try:
761+
app = self.get_content_by_id(app_id)
762+
except RSConnectException as e:
763+
raise RSConnectException(
764+
f"{e} Try setting the --new flag or omit --app-id to create new content."
765+
) from e
766+
767+
app_guid = app["guid"]
768+
769+
# Map subdirectory to directory (API uses "directory" field)
770+
directory = subdirectory if subdirectory else "."
771+
772+
# Check if content already has git configuration
773+
existing_repo = self.get_repository(app_guid)
774+
775+
if existing_repo:
776+
# Update existing git configuration using PATCH
777+
self.update_repository(
778+
app_guid,
779+
repository=repository,
780+
branch=branch,
781+
directory=directory,
782+
polling=polling,
783+
)
784+
else:
785+
# Create new git configuration using PUT
786+
self.set_repository(
787+
app_guid,
788+
repository=repository,
789+
branch=branch,
790+
directory=directory,
791+
polling=polling,
792+
)
793+
794+
# Update title if provided (and different from current)
795+
if title and app.get("title") != title:
796+
self.patch("v1/content/%s" % app_guid, body={"title": title})
797+
798+
# Set environment variables
799+
if env_vars:
800+
result = self.add_environment_vars(app_guid, list(env_vars.items()))
801+
self._server.handle_bad_response(result)
802+
803+
# Trigger deployment (bundle_id=None uses the latest bundle from git clone)
804+
task = self.content_deploy(app_guid, bundle_id=None)
805+
806+
return RSConnectClientDeployResult(
807+
app_id=str(app["id"]),
808+
app_guid=app_guid,
809+
app_url=app["content_url"],
810+
task_id=task["task_id"],
811+
title=title or app.get("title"),
812+
dashboard_url=app["dashboard_url"],
813+
draft_url=None,
814+
)
815+
595816
def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]:
596817
response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime"))
597818
response = self._server.handle_bad_response(response)
@@ -784,6 +1005,10 @@ def __init__(
7841005
disable_env_management: Optional[bool] = None,
7851006
env_vars: Optional[dict[str, str]] = None,
7861007
metadata: Optional[dict[str, str]] = None,
1008+
repository: Optional[str] = None,
1009+
branch: Optional[str] = None,
1010+
subdirectory: Optional[str] = None,
1011+
polling: bool = True,
7871012
) -> None:
7881013
self.remote_server: TargetableServer
7891014
self.client: RSConnectClient | PositClient
@@ -805,6 +1030,12 @@ def __init__(
8051030
self.title_is_default: bool = not title
8061031
self.deployment_name: str | None = None
8071032

1033+
# Git deployment parameters
1034+
self.repository: str | None = repository
1035+
self.branch: str | None = branch
1036+
self.subdirectory: str | None = subdirectory
1037+
self.polling: bool = polling
1038+
8081039
self.bundle: IO[bytes] | None = None
8091040
self.deployed_info: RSConnectClientDeployResult | None = None
8101041

@@ -847,6 +1078,10 @@ def fromConnectServer(
8471078
disable_env_management: Optional[bool] = None,
8481079
env_vars: Optional[dict[str, str]] = None,
8491080
metadata: Optional[dict[str, str]] = None,
1081+
repository: Optional[str] = None,
1082+
branch: Optional[str] = None,
1083+
subdirectory: Optional[str] = None,
1084+
polling: bool = True,
8501085
):
8511086
return cls(
8521087
ctx=ctx,
@@ -870,6 +1105,10 @@ def fromConnectServer(
8701105
disable_env_management=disable_env_management,
8711106
env_vars=env_vars,
8721107
metadata=metadata,
1108+
repository=repository,
1109+
branch=branch,
1110+
subdirectory=subdirectory,
1111+
polling=polling,
8731112
)
8741113

8751114
def output_overlap_header(self, previous: bool) -> bool:
@@ -1169,6 +1408,49 @@ def deploy_bundle(self, activate: bool = True):
11691408
)
11701409
return self
11711410

1411+
@cls_logged("Creating git-backed deployment ...")
1412+
def deploy_git(self):
1413+
"""Deploy content from a remote git repository.
1414+
1415+
Creates a git-backed content item in Posit Connect. Connect will clone
1416+
the repository and automatically redeploy when commits are pushed.
1417+
"""
1418+
if not isinstance(self.client, RSConnectClient):
1419+
raise RSConnectException(
1420+
"Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1421+
)
1422+
1423+
if not self.repository:
1424+
raise RSConnectException("Repository URL is required for git deployment.")
1425+
1426+
# Generate a valid deployment name from the title
1427+
# This sanitizes characters like "/" that aren't allowed in names
1428+
force_unique_name = self.app_id is None
1429+
deployment_name = self.make_deployment_name(self.title, force_unique_name)
1430+
1431+
try:
1432+
result = self.client.deploy_git(
1433+
app_id=self.app_id,
1434+
name=deployment_name,
1435+
repository=self.repository,
1436+
branch=self.branch or "main",
1437+
subdirectory=self.subdirectory or "",
1438+
title=self.title,
1439+
env_vars=self.env_vars,
1440+
polling=self.polling,
1441+
)
1442+
except RSConnectException as e:
1443+
# Check for 404 on /repo endpoint (git not enabled)
1444+
if "404" in str(e) and "repo" in str(e).lower():
1445+
raise RSConnectException(
1446+
"Git-backed deployment is not enabled on this Connect server. "
1447+
"Contact your administrator to enable Git support."
1448+
) from e
1449+
raise
1450+
1451+
self.deployed_info = result
1452+
return self
1453+
11721454
def emit_task_log(
11731455
self,
11741456
log_callback: logging.Logger = connect_logger,

0 commit comments

Comments
 (0)