Skip to content

Commit d9cbc3e

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 <[email protected]>
1 parent 4c32231 commit d9cbc3e

File tree

5 files changed

+449
-6
lines changed

5 files changed

+449
-6
lines changed

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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,73 @@ def content_deploy(
592592
response = self._server.handle_bad_response(response)
593593
return response
594594

595+
def deploy_git(
596+
self,
597+
app_id: Optional[str],
598+
name: str,
599+
repository: str,
600+
branch: str,
601+
subdirectory: str,
602+
title: Optional[str],
603+
env_vars: Optional[dict[str, str]],
604+
) -> RSConnectClientDeployResult:
605+
"""Deploy content from a git repository.
606+
607+
Creates a git-backed content item in Posit Connect. Connect will clone
608+
the repository and automatically redeploy when commits are pushed.
609+
610+
:param app_id: Existing content ID/GUID to update, or None to create new content
611+
:param name: Name for the content item (used if creating new)
612+
:param repository: URL of the git repository (https:// only)
613+
:param branch: Branch to deploy from
614+
:param subdirectory: Subdirectory containing manifest.json
615+
:param title: Title for the content
616+
:param env_vars: Environment variables to set
617+
:return: Deployment result with task_id, app info, etc.
618+
"""
619+
# Create or get existing content
620+
if app_id is None:
621+
app = self.content_create(name)
622+
else:
623+
try:
624+
app = self.get_content_by_id(app_id)
625+
except RSConnectException as e:
626+
raise RSConnectException(
627+
f"{e} Try setting the --new flag or omit --app-id to create new content."
628+
) from e
629+
630+
app_guid = app["guid"]
631+
632+
# Set repository info via POST to applications/{guid}/repo
633+
# This is a Connect-specific endpoint for git-backed content
634+
resp = self.post(
635+
"applications/%s/repo" % app_guid,
636+
body={"repository": repository, "branch": branch, "subdirectory": subdirectory},
637+
)
638+
self._server.handle_bad_response(resp)
639+
640+
# Update title if provided (and different from current)
641+
if title and app.get("title") != title:
642+
self.patch("v1/content/%s" % app_guid, body={"title": title})
643+
644+
# Set environment variables
645+
if env_vars:
646+
result = self.add_environment_vars(app_guid, list(env_vars.items()))
647+
self._server.handle_bad_response(result)
648+
649+
# Trigger deployment (bundle_id=None uses the latest bundle from git clone)
650+
task = self.content_deploy(app_guid, bundle_id=None)
651+
652+
return RSConnectClientDeployResult(
653+
app_id=str(app["id"]),
654+
app_guid=app_guid,
655+
app_url=app["content_url"],
656+
task_id=task["task_id"],
657+
title=title or app.get("title"),
658+
dashboard_url=app["dashboard_url"],
659+
draft_url=None,
660+
)
661+
595662
def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]:
596663
response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime"))
597664
response = self._server.handle_bad_response(response)
@@ -784,6 +851,9 @@ def __init__(
784851
disable_env_management: Optional[bool] = None,
785852
env_vars: Optional[dict[str, str]] = None,
786853
metadata: Optional[dict[str, str]] = None,
854+
repository: Optional[str] = None,
855+
branch: Optional[str] = None,
856+
subdirectory: Optional[str] = None,
787857
) -> None:
788858
self.remote_server: TargetableServer
789859
self.client: RSConnectClient | PositClient
@@ -805,6 +875,11 @@ def __init__(
805875
self.title_is_default: bool = not title
806876
self.deployment_name: str | None = None
807877

878+
# Git deployment parameters
879+
self.repository: str | None = repository
880+
self.branch: str | None = branch
881+
self.subdirectory: str | None = subdirectory
882+
808883
self.bundle: IO[bytes] | None = None
809884
self.deployed_info: RSConnectClientDeployResult | None = None
810885

@@ -847,6 +922,9 @@ def fromConnectServer(
847922
disable_env_management: Optional[bool] = None,
848923
env_vars: Optional[dict[str, str]] = None,
849924
metadata: Optional[dict[str, str]] = None,
925+
repository: Optional[str] = None,
926+
branch: Optional[str] = None,
927+
subdirectory: Optional[str] = None,
850928
):
851929
return cls(
852930
ctx=ctx,
@@ -870,6 +948,9 @@ def fromConnectServer(
870948
disable_env_management=disable_env_management,
871949
env_vars=env_vars,
872950
metadata=metadata,
951+
repository=repository,
952+
branch=branch,
953+
subdirectory=subdirectory,
873954
)
874955

875956
def output_overlap_header(self, previous: bool) -> bool:
@@ -1169,6 +1250,48 @@ def deploy_bundle(self, activate: bool = True):
11691250
)
11701251
return self
11711252

1253+
@cls_logged("Creating git-backed deployment ...")
1254+
def deploy_git(self):
1255+
"""Deploy content from a remote git repository.
1256+
1257+
Creates a git-backed content item in Posit Connect. Connect will clone
1258+
the repository and automatically redeploy when commits are pushed.
1259+
"""
1260+
if not isinstance(self.client, RSConnectClient):
1261+
raise RSConnectException(
1262+
"Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
1263+
)
1264+
1265+
if not self.repository:
1266+
raise RSConnectException("Repository URL is required for git deployment.")
1267+
1268+
# Generate a valid deployment name from the title
1269+
# This sanitizes characters like "/" that aren't allowed in names
1270+
force_unique_name = self.app_id is None
1271+
deployment_name = self.make_deployment_name(self.title, force_unique_name)
1272+
1273+
try:
1274+
result = self.client.deploy_git(
1275+
app_id=self.app_id,
1276+
name=deployment_name,
1277+
repository=self.repository,
1278+
branch=self.branch or "main",
1279+
subdirectory=self.subdirectory or "",
1280+
title=self.title,
1281+
env_vars=self.env_vars,
1282+
)
1283+
except RSConnectException as e:
1284+
# Check for 404 on /repo endpoint (git not enabled)
1285+
if "404" in str(e) and "repo" in str(e).lower():
1286+
raise RSConnectException(
1287+
"Git-backed deployment is not enabled on this Connect server. "
1288+
"Contact your administrator to enable Git support."
1289+
) from e
1290+
raise
1291+
1292+
self.deployed_info = result
1293+
return self
1294+
11721295
def emit_task_log(
11731296
self,
11741297
log_callback: logging.Logger = connect_logger,

rsconnect/main.py

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import functools
44
import json
55
import os
6-
import sys
7-
import textwrap
8-
import traceback
96
import shutil
107
import subprocess
8+
import sys
119
import tempfile
10+
import textwrap
11+
import traceback
1212
from functools import wraps
1313
from os.path import abspath, dirname, exists, isdir, join
1414
from typing import (
@@ -91,7 +91,7 @@
9191
write_tensorflow_manifest_json,
9292
write_voila_manifest_json,
9393
)
94-
from .environment import Environment, fake_module_file_from_directory
94+
from .environment import Environment, PackageInstaller, fake_module_file_from_directory
9595
from .exception import RSConnectException
9696
from .git_metadata import detect_git_metadata
9797
from .json_web_token import (
@@ -113,7 +113,6 @@
113113
VersionSearchFilter,
114114
VersionSearchFilterParamType,
115115
)
116-
from .environment import PackageInstaller
117116
from .shiny_express import escape_to_var_name, is_express_app
118117
from .utils_package import fix_starlette_requirements
119118

@@ -327,6 +326,23 @@ def prepare_deploy_metadata(
327326
return None
328327

329328

329+
def _generate_git_title(repository: str, subdirectory: str) -> str:
330+
"""Generate a title from repository URL and subdirectory.
331+
332+
:param repository: URL of the git repository
333+
:param subdirectory: Subdirectory within the repository
334+
:return: Generated title string
335+
"""
336+
# Extract repo name from URL (e.g., "https://github.com/user/repo" -> "repo")
337+
repo_name = repository.rstrip("/").split("/")[-1]
338+
if repo_name.endswith(".git"):
339+
repo_name = repo_name[:-4]
340+
341+
if subdirectory and subdirectory != "/" and subdirectory.strip("/"):
342+
return f"{repo_name}/{subdirectory.strip('/')}"
343+
return repo_name
344+
345+
330346
def content_args(func: Callable[P, T]) -> Callable[P, T]:
331347
@click.option(
332348
"--new",
@@ -1474,6 +1490,92 @@ def deploy_manifest(
14741490
ce.verify_deployment()
14751491

14761492

1493+
@deploy.command(
1494+
name="git",
1495+
short_help="Deploy content from a Git repository to Posit Connect.",
1496+
help=(
1497+
"Deploy content to Posit Connect directly from a remote Git repository. "
1498+
"The repository must contain a manifest.json file (in the root or specified subdirectory). "
1499+
"Connect will periodically check for updates and redeploy automatically when commits are pushed."
1500+
"\n\n"
1501+
"This command creates a new git-backed content item. To update an existing git-backed "
1502+
"content item, use the --app-id option with the content's GUID."
1503+
),
1504+
)
1505+
@server_args
1506+
@spcs_args
1507+
@content_args
1508+
@click.option(
1509+
"--repository",
1510+
"-r",
1511+
required=True,
1512+
help="URL of the Git repository (https:// URLs only).",
1513+
)
1514+
@click.option(
1515+
"--branch",
1516+
"-b",
1517+
default="main",
1518+
help="Branch to deploy from. Connect auto-deploys when commits are pushed. [default: main]",
1519+
)
1520+
@click.option(
1521+
"--subdirectory",
1522+
"-d",
1523+
default="",
1524+
help="Subdirectory containing manifest.json. Use path syntax (e.g., 'path/to/content').",
1525+
)
1526+
@cli_exception_handler
1527+
@click.pass_context
1528+
def deploy_git(
1529+
ctx: click.Context,
1530+
name: Optional[str],
1531+
server: Optional[str],
1532+
api_key: Optional[str],
1533+
snowflake_connection_name: Optional[str],
1534+
insecure: bool,
1535+
cacert: Optional[str],
1536+
verbose: int,
1537+
new: bool,
1538+
app_id: Optional[str],
1539+
title: Optional[str],
1540+
env_vars: dict[str, str],
1541+
no_verify: bool,
1542+
draft: bool,
1543+
metadata: tuple[str, ...],
1544+
no_metadata: bool,
1545+
repository: str,
1546+
branch: str,
1547+
subdirectory: str,
1548+
):
1549+
set_verbosity(verbose)
1550+
output_params(ctx, locals().items())
1551+
1552+
# Generate title if not provided
1553+
if not title:
1554+
title = _generate_git_title(repository, subdirectory)
1555+
1556+
ce = RSConnectExecutor(
1557+
ctx=ctx,
1558+
name=name,
1559+
api_key=api_key,
1560+
snowflake_connection_name=snowflake_connection_name,
1561+
insecure=insecure,
1562+
cacert=cacert,
1563+
server=server,
1564+
new=new,
1565+
app_id=app_id,
1566+
title=title,
1567+
env_vars=env_vars,
1568+
repository=repository,
1569+
branch=branch,
1570+
subdirectory=subdirectory.strip("/") if subdirectory else "",
1571+
)
1572+
1573+
ce.validate_server().deploy_git().emit_task_log()
1574+
1575+
if not no_verify:
1576+
ce.verify_deployment()
1577+
1578+
14771579
# noinspection SpellCheckingInspection,DuplicatedCode
14781580
@deploy.command(
14791581
name="quarto",

0 commit comments

Comments
 (0)