Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

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


## [1.28.2] - 2025-12-05

### Fixed
Expand Down
123 changes: 123 additions & 0 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,73 @@ def content_deploy(
response = self._server.handle_bad_response(response)
return response

def deploy_git(
self,
app_id: Optional[str],
name: str,
repository: str,
branch: str,
subdirectory: str,
title: Optional[str],
env_vars: Optional[dict[str, str]],
) -> RSConnectClientDeployResult:
"""Deploy content from a git repository.

Creates a git-backed content item in Posit Connect. Connect will clone
the repository and automatically redeploy when commits are pushed.

:param app_id: Existing content ID/GUID to update, or None to create new content
:param name: Name for the content item (used if creating new)
:param repository: URL of the git repository (https:// only)
:param branch: Branch to deploy from
:param subdirectory: Subdirectory containing manifest.json
:param title: Title for the content
:param env_vars: Environment variables to set
:return: Deployment result with task_id, app info, etc.
"""
# Create or get existing content
if app_id is None:
app = self.content_create(name)
else:
try:
app = self.get_content_by_id(app_id)
except RSConnectException as e:
raise RSConnectException(
f"{e} Try setting the --new flag or omit --app-id to create new content."
) from e

app_guid = app["guid"]

# Set repository info via POST to applications/{guid}/repo
# This is a Connect-specific endpoint for git-backed content
resp = self.post(
"applications/%s/repo" % app_guid,
body={"repository": repository, "branch": branch, "subdirectory": subdirectory},
)
self._server.handle_bad_response(resp)

# Update title if provided (and different from current)
if title and app.get("title") != title:
self.patch("v1/content/%s" % app_guid, body={"title": title})

# Set environment variables
if env_vars:
result = self.add_environment_vars(app_guid, list(env_vars.items()))
self._server.handle_bad_response(result)

# Trigger deployment (bundle_id=None uses the latest bundle from git clone)
task = self.content_deploy(app_guid, bundle_id=None)

return RSConnectClientDeployResult(
app_id=str(app["id"]),
app_guid=app_guid,
app_url=app["content_url"],
task_id=task["task_id"],
title=title or app.get("title"),
dashboard_url=app["dashboard_url"],
draft_url=None,
)

def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]:
response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime"))
response = self._server.handle_bad_response(response)
Expand Down Expand Up @@ -784,6 +851,9 @@ def __init__(
disable_env_management: Optional[bool] = None,
env_vars: Optional[dict[str, str]] = None,
metadata: Optional[dict[str, str]] = None,
repository: Optional[str] = None,
branch: Optional[str] = None,
subdirectory: Optional[str] = None,
) -> None:
self.remote_server: TargetableServer
self.client: RSConnectClient | PositClient
Expand All @@ -805,6 +875,11 @@ def __init__(
self.title_is_default: bool = not title
self.deployment_name: str | None = None

# Git deployment parameters
self.repository: str | None = repository
self.branch: str | None = branch
self.subdirectory: str | None = subdirectory

self.bundle: IO[bytes] | None = None
self.deployed_info: RSConnectClientDeployResult | None = None

Expand Down Expand Up @@ -847,6 +922,9 @@ def fromConnectServer(
disable_env_management: Optional[bool] = None,
env_vars: Optional[dict[str, str]] = None,
metadata: Optional[dict[str, str]] = None,
repository: Optional[str] = None,
branch: Optional[str] = None,
subdirectory: Optional[str] = None,
):
return cls(
ctx=ctx,
Expand All @@ -870,6 +948,9 @@ def fromConnectServer(
disable_env_management=disable_env_management,
env_vars=env_vars,
metadata=metadata,
repository=repository,
branch=branch,
subdirectory=subdirectory,
)

def output_overlap_header(self, previous: bool) -> bool:
Expand Down Expand Up @@ -1169,6 +1250,48 @@ def deploy_bundle(self, activate: bool = True):
)
return self

@cls_logged("Creating git-backed deployment ...")
def deploy_git(self):
"""Deploy content from a remote git repository.

Creates a git-backed content item in Posit Connect. Connect will clone
the repository and automatically redeploy when commits are pushed.
"""
if not isinstance(self.client, RSConnectClient):
raise RSConnectException(
"Git deployment is only supported for Posit Connect servers, " "not shinyapps.io or Posit Cloud."
)

if not self.repository:
raise RSConnectException("Repository URL is required for git deployment.")

# Generate a valid deployment name from the title
# This sanitizes characters like "/" that aren't allowed in names
force_unique_name = self.app_id is None
deployment_name = self.make_deployment_name(self.title, force_unique_name)

try:
result = self.client.deploy_git(
app_id=self.app_id,
name=deployment_name,
repository=self.repository,
branch=self.branch or "main",
subdirectory=self.subdirectory or "",
title=self.title,
env_vars=self.env_vars,
)
except RSConnectException as e:
# Check for 404 on /repo endpoint (git not enabled)
if "404" in str(e) and "repo" in str(e).lower():
raise RSConnectException(
"Git-backed deployment is not enabled on this Connect server. "
"Contact your administrator to enable Git support."
) from e
raise

self.deployed_info = result
return self

def emit_task_log(
self,
log_callback: logging.Logger = connect_logger,
Expand Down
112 changes: 107 additions & 5 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import functools
import json
import os
import sys
import textwrap
import traceback
import shutil
import subprocess
import sys
import tempfile
import textwrap
import traceback
from functools import wraps
from os.path import abspath, dirname, exists, isdir, join
from typing import (
Expand Down Expand Up @@ -91,7 +91,7 @@
write_tensorflow_manifest_json,
write_voila_manifest_json,
)
from .environment import Environment, fake_module_file_from_directory
from .environment import Environment, PackageInstaller, fake_module_file_from_directory
from .exception import RSConnectException
from .git_metadata import detect_git_metadata
from .json_web_token import (
Expand All @@ -113,7 +113,6 @@
VersionSearchFilter,
VersionSearchFilterParamType,
)
from .environment import PackageInstaller
from .shiny_express import escape_to_var_name, is_express_app
from .utils_package import fix_starlette_requirements

Expand Down Expand Up @@ -327,6 +326,23 @@ def prepare_deploy_metadata(
return None


def _generate_git_title(repository: str, subdirectory: str) -> str:
"""Generate a title from repository URL and subdirectory.

:param repository: URL of the git repository
:param subdirectory: Subdirectory within the repository
:return: Generated title string
"""
# Extract repo name from URL (e.g., "https://github.com/user/repo" -> "repo")
repo_name = repository.rstrip("/").split("/")[-1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]

if subdirectory and subdirectory != "/" and subdirectory.strip("/"):
return f"{repo_name}/{subdirectory.strip('/')}"
return repo_name


def content_args(func: Callable[P, T]) -> Callable[P, T]:
@click.option(
"--new",
Expand Down Expand Up @@ -1474,6 +1490,92 @@ def deploy_manifest(
ce.verify_deployment()


@deploy.command(
name="git",
short_help="Deploy content from a Git repository to Posit Connect.",
help=(
"Deploy content to Posit Connect directly from a remote Git repository. "
"The repository must contain a manifest.json file (in the root or specified subdirectory). "
"Connect will periodically check for updates and redeploy automatically when commits are pushed."
"\n\n"
"This command creates a new git-backed content item. To update an existing git-backed "
"content item, use the --app-id option with the content's GUID."
),
)
@server_args
@spcs_args
@content_args
@click.option(
"--repository",
"-r",
required=True,
help="URL of the Git repository (https:// URLs only).",
)
@click.option(
"--branch",
"-b",
default="main",
help="Branch to deploy from. Connect auto-deploys when commits are pushed. [default: main]",
)
@click.option(
"--subdirectory",
"-d",
default="",
help="Subdirectory containing manifest.json. Use path syntax (e.g., 'path/to/content').",
)
@cli_exception_handler
@click.pass_context
def deploy_git(
ctx: click.Context,
name: Optional[str],
server: Optional[str],
api_key: Optional[str],
snowflake_connection_name: Optional[str],
insecure: bool,
cacert: Optional[str],
verbose: int,
new: bool,
app_id: Optional[str],
title: Optional[str],
env_vars: dict[str, str],
no_verify: bool,
draft: bool,
metadata: tuple[str, ...],
no_metadata: bool,
repository: str,
branch: str,
subdirectory: str,
):
set_verbosity(verbose)
output_params(ctx, locals().items())

# Generate title if not provided
if not title:
title = _generate_git_title(repository, subdirectory)

ce = RSConnectExecutor(
ctx=ctx,
name=name,
api_key=api_key,
snowflake_connection_name=snowflake_connection_name,
insecure=insecure,
cacert=cacert,
server=server,
new=new,
app_id=app_id,
title=title,
env_vars=env_vars,
repository=repository,
branch=branch,
subdirectory=subdirectory.strip("/") if subdirectory else "",
)

ce.validate_server().deploy_git().emit_task_log()

if not no_verify:
ce.verify_deployment()


# noinspection SpellCheckingInspection,DuplicatedCode
@deploy.command(
name="quarto",
Expand Down
Loading
Loading