Skip to content

Commit 539ec88

Browse files
feat: add metadata to bundle uploads (#736)
1 parent 11a10b0 commit 539ec88

File tree

7 files changed

+696
-13
lines changed

7 files changed

+696
-13
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
*.orig
12
*.pyc
23
.DS_Store
34
.coverage

docs/CHANGELOG.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased] - ??
8+
## Unreleased
99

1010
### Added
1111

1212
- `rsconnect content get-lockfile` command allows fetching a lockfile with the
13-
dependencies installed by connect to run the deployed content
13+
dependencies installed by connect to run the deployed content
1414
- `rsconnect content venv` command recreates a local python environment
15-
equal to the one used by connect to run the content.
15+
equal to the one used by connect to run the content.
1616
- Added `--requirements-file` option on deploy and write-manifest commands to
17-
supply an explicit requirements file instead of detecting the environment.
18-
17+
supply an explicit requirements file instead of detecting the environment.
18+
- Bundle uploads now include git metadata (source, source_repo, source_branch, source_commit)
19+
when deploying from a git repository. This metadata is automatically detected and sent to
20+
Posit Connect 2025.12.0 or later. Use `--metadata key=value` to provide additional metadata
21+
or override detected values. Use `--no-metadata` to disable automatic detection. (#736)
1922

2023
## [1.28.2] - 2025-12-05
2124

rsconnect/api.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import datetime
1010
import hashlib
1111
import hmac
12+
import json
1213
import os
1314
import re
1415
import sys
@@ -55,7 +56,14 @@
5556
from .certificates import read_certificate_file
5657
from .environment import fake_module_file_from_directory
5758
from .exception import DeploymentFailedException, RSConnectException
58-
from .http_support import CookieJar, HTTPResponse, HTTPServer, JsonData, append_to_path
59+
from .http_support import (
60+
CookieJar,
61+
HTTPResponse,
62+
HTTPServer,
63+
JsonData,
64+
append_to_path,
65+
create_multipart_form_data,
66+
)
5967
from .log import cls_logged, connect_logger, console_logger, logger
6068
from .metadata import AppStore, ServerStore
6169
from .models import (
@@ -76,6 +84,7 @@
7684
)
7785
from .snowflake import generate_jwt, get_parameters
7886
from .timeouts import get_task_timeout, get_task_timeout_help_message
87+
from .utils_package import compare_semvers
7988

8089
if TYPE_CHECKING:
8190
import logging
@@ -367,6 +376,26 @@ class RSConnectClientDeployResult(TypedDict):
367376
title: str | None
368377

369378

379+
def server_supports_git_metadata(server_version: Optional[str]) -> bool:
380+
"""
381+
Check if the server version supports git metadata in bundle uploads.
382+
383+
Git metadata support was added in Connect 2025.12.0.
384+
385+
:param server_version: The Connect server version string
386+
:return: True if the server supports git metadata, False otherwise
387+
"""
388+
if not server_version:
389+
return False
390+
391+
try:
392+
return compare_semvers(server_version, "2025.11.0") > 0
393+
except Exception:
394+
# If we can't parse the version, assume it doesn't support it
395+
logger.debug(f"Unable to parse server version: {server_version}")
396+
return False
397+
398+
370399
class RSConnectClient(HTTPServer):
371400
def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None):
372401
if cookies is None:
@@ -496,11 +525,34 @@ def content_create(self, name: str) -> ContentItemV1:
496525
response = self._server.handle_bad_response(response)
497526
return response
498527

499-
def content_upload_bundle(self, content_guid: str, tarball: typing.IO[bytes]) -> BundleMetadata:
500-
response = cast(
501-
Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball)
502-
)
503-
response = self._server.handle_bad_response(response)
528+
def upload_bundle(
529+
self, content_guid: str, tarball: typing.IO[bytes], metadata: Optional[dict[str, str]] = None
530+
) -> BundleMetadata:
531+
"""
532+
Upload a bundle to the server.
533+
534+
:param app_id: Application ID
535+
:param tarball: Bundle tarball file object
536+
:param metadata: Optional metadata dictionary (e.g., git metadata)
537+
:return: ContentItemV0 with bundle information
538+
"""
539+
if metadata:
540+
# Use multipart form upload when metadata is provided
541+
tarball_content = tarball.read()
542+
fields = {
543+
"archive": ("bundle.tar.gz", tarball_content, "application/x-tar"),
544+
"metadata": json.dumps(metadata),
545+
}
546+
body, content_type = create_multipart_form_data(fields)
547+
response = cast(
548+
Union[BundleMetadata, HTTPResponse],
549+
self.post("v1/content/%s/bundles" % content_guid, body=body, headers={"Content-Type": content_type}),
550+
)
551+
else:
552+
response = cast(
553+
Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball)
554+
)
555+
response = self._server.handle_bad_response(response)
504556
return response
505557

506558
def content_update(self, content_guid: str, updates: Mapping[str, str | None]) -> ContentItemV1:
@@ -581,6 +633,7 @@ def deploy(
581633
tarball: IO[bytes],
582634
env_vars: Optional[dict[str, str]] = None,
583635
activate: bool = True,
636+
metadata: Optional[dict[str, str]] = None,
584637
) -> RSConnectClientDeployResult:
585638
if app_id is None:
586639
if app_name is None:
@@ -608,7 +661,7 @@ def deploy(
608661
result = self._server.handle_bad_response(result)
609662
app["title"] = app_title
610663

611-
app_bundle = self.content_upload_bundle(app_guid, tarball)
664+
app_bundle = self.upload_bundle(app_guid, tarball, metadata=metadata)
612665

613666
task = self.content_deploy(app_guid, app_bundle["id"], activate=activate)
614667

@@ -730,6 +783,7 @@ def __init__(
730783
visibility: Optional[str] = None,
731784
disable_env_management: Optional[bool] = None,
732785
env_vars: Optional[dict[str, str]] = None,
786+
metadata: Optional[dict[str, str]] = None,
733787
) -> None:
734788
self.remote_server: TargetableServer
735789
self.client: RSConnectClient | PositClient
@@ -743,6 +797,7 @@ def __init__(
743797
self.visibility = visibility
744798
self.disable_env_management = disable_env_management
745799
self.env_vars = env_vars
800+
self.metadata = metadata
746801
self.app_mode: AppMode | None = None
747802
self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path))
748803
self.app_store_version: int | None = None
@@ -791,6 +846,7 @@ def fromConnectServer(
791846
visibility: Optional[str] = None,
792847
disable_env_management: Optional[bool] = None,
793848
env_vars: Optional[dict[str, str]] = None,
849+
metadata: Optional[dict[str, str]] = None,
794850
):
795851
return cls(
796852
ctx=ctx,
@@ -813,6 +869,7 @@ def fromConnectServer(
813869
visibility=visibility,
814870
disable_env_management=disable_env_management,
815871
env_vars=env_vars,
872+
metadata=metadata,
816873
)
817874

818875
def output_overlap_header(self, previous: bool) -> bool:
@@ -1075,6 +1132,7 @@ def deploy_bundle(self, activate: bool = True):
10751132
self.bundle,
10761133
self.env_vars,
10771134
activate=activate,
1135+
metadata=self.metadata,
10781136
)
10791137
self.deployed_info = result
10801138
return self

rsconnect/git_metadata.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""
2+
Git metadata detection utilities for bundle uploads
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import subprocess
8+
from typing import Optional
9+
from urllib.parse import urlparse
10+
11+
from .log import logger
12+
13+
14+
def _run_git_command(args: list[str], cwd: str) -> Optional[str]:
15+
"""
16+
Run a git command and return its output.
17+
18+
:param args: git command arguments
19+
:param cwd: working directory
20+
:return: command output or None if command failed
21+
"""
22+
try:
23+
result = subprocess.run(
24+
["git"] + args,
25+
cwd=cwd,
26+
capture_output=True,
27+
text=True,
28+
timeout=5,
29+
)
30+
if result.returncode == 0:
31+
return result.stdout.strip()
32+
return None
33+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
34+
return None
35+
36+
37+
def is_git_repo(directory: str) -> bool:
38+
"""
39+
Check if directory is inside a git repository.
40+
41+
:param directory: directory to check
42+
:return: True if inside a git repo, False otherwise
43+
"""
44+
result = _run_git_command(["rev-parse", "--git-dir"], directory)
45+
return result is not None
46+
47+
48+
def has_uncommitted_changes(directory: str) -> bool:
49+
"""
50+
Check if the git repository has uncommitted changes.
51+
52+
:param directory: directory to check
53+
:return: True if there are uncommitted changes
54+
"""
55+
# Check for staged and unstaged changes
56+
result = _run_git_command(["status", "--porcelain"], directory)
57+
return bool(result)
58+
59+
60+
def get_git_commit(directory: str) -> Optional[str]:
61+
"""
62+
Get the current git commit SHA.
63+
64+
:param directory: directory to check
65+
:return: commit SHA or None
66+
"""
67+
return _run_git_command(["rev-parse", "HEAD"], directory)
68+
69+
70+
def get_git_branch(directory: str) -> Optional[str]:
71+
"""
72+
Get the current git branch name or tag.
73+
74+
:param directory: directory to check
75+
:return: branch/tag name or None
76+
"""
77+
# First try to get branch name
78+
branch = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], directory)
79+
80+
# If we're in detached HEAD state, try to get tag
81+
if branch == "HEAD":
82+
tag = _run_git_command(["describe", "--exact-match", "--tags"], directory)
83+
if tag:
84+
return tag
85+
86+
return branch
87+
88+
89+
def get_git_remote_url(directory: str, remote: str = "origin") -> Optional[str]:
90+
"""
91+
Get the URL of a git remote.
92+
93+
:param directory: directory to check
94+
:param remote: remote name (default: "origin")
95+
:return: remote URL or None
96+
"""
97+
return _run_git_command(["remote", "get-url", remote], directory)
98+
99+
100+
def normalize_git_url_to_https(url: Optional[str]) -> Optional[str]:
101+
"""
102+
Normalize a git URL to HTTPS format.
103+
104+
Converts SSH URLs like [email protected]:user/repo.git to
105+
https://github.com/user/repo.git
106+
107+
:param url: git URL to normalize
108+
:return: normalized HTTPS URL or original if already HTTPS/not recognized
109+
"""
110+
if not url:
111+
return url
112+
113+
# Already HTTPS
114+
if url.startswith("https://"):
115+
return url
116+
117+
# Handle git@ SSH format
118+
if url.startswith("git@"):
119+
# [email protected]:user/repo.git -> https://github.com/user/repo.git
120+
# Remove git@ prefix
121+
url = url[4:]
122+
# Replace first : with /
123+
url = url.replace(":", "/", 1)
124+
# Add https://
125+
return f"https://{url}"
126+
127+
# Handle ssh:// format
128+
if url.startswith("ssh://"):
129+
# ssh://[email protected]/user/repo.git -> https://github.com/user/repo.git
130+
parsed = urlparse(url)
131+
if parsed.hostname:
132+
path = parsed.path
133+
return f"https://{parsed.hostname}{path}"
134+
135+
# Return as-is if we can't normalize
136+
return url
137+
138+
139+
def detect_git_metadata(directory: str, remote: str = "origin") -> dict[str, str]:
140+
"""
141+
Detect git metadata for the given directory.
142+
143+
:param directory: directory to inspect
144+
:param remote: git remote name to use (default: "origin")
145+
:return: dictionary with source, source_repo, source_branch, source_commit keys
146+
"""
147+
metadata: dict[str, str] = {}
148+
149+
if not is_git_repo(directory):
150+
logger.debug(f"Directory {directory} is not a git repository")
151+
return metadata
152+
153+
# Get commit SHA
154+
commit = get_git_commit(directory)
155+
if commit:
156+
# Check for uncommitted changes
157+
if has_uncommitted_changes(directory):
158+
commit = f"{commit}-dirty"
159+
metadata["source_commit"] = commit
160+
161+
# Get branch/tag
162+
branch = get_git_branch(directory)
163+
if branch:
164+
metadata["source_branch"] = branch
165+
166+
# Get remote URL and normalize to HTTPS
167+
remote_url = get_git_remote_url(directory, remote)
168+
if remote_url:
169+
normalized_url = normalize_git_url_to_https(remote_url)
170+
if normalized_url:
171+
metadata["source_repo"] = normalized_url
172+
173+
# Always set source to "git" if we got any metadata
174+
if metadata:
175+
metadata["source"] = "git"
176+
logger.debug(f"Detected git metadata: {metadata}")
177+
178+
return metadata

0 commit comments

Comments
 (0)