Skip to content
Merged
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
55 changes: 37 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ Without `bash -c`, the environment variables would be evaluated before `with-con

### Options

- `--version`: Specify the Connect version (default: release). Use "latest" or "release" for the most recent version, or specify a version like "2024.08.0", or a known docker tag.
- `--license`: Path to license file (default: ./rstudio-connect.lic). This file must exist and be a valid Connect license.
- `--config`: Path to optional rstudio-connect.gcfg configuration file
- `--port`: Port to map the Connect container to (default: 3939). Allows running multiple Connect instances simultaneously.
- `-e`, `--env`: Environment variables to pass to the Docker container (format: KEY=VALUE). Can be specified multiple times.
| Option | Default | Description |
|---------------|-------------------------|----------------------------------------------------------------------------------------------------------------------|
| `--version` | `release` | Posit Connect version. Use "latest" or "release" for the most recent version, or specify a version like "2024.08.0". |
| `--license` | `./rstudio-connect.lic` | Path to license file. This file must exist and be a valid Connect license. |
| `--image` | | Container image to use, including tag (e.g., `posit/connect:2025.12.0`). Overrides `--version`. |
| `--config` | | Path to optional rstudio-connect.gcfg configuration file |
| `--port` | `3939` | Port to map the Connect container to. Allows running multiple Connect instances simultaneously. |
| `-e`, `--env` | | Environment variables to pass to the Docker container (format: KEY=VALUE). Can be specified multiple times. |

Example:

Expand All @@ -89,6 +92,23 @@ This project contains a GitHub Action for use in CI/CD workflows. Use the `@v1`

You will need to store your Posit Connect license file as a GitHub secret (e.g., `CONNECT_LICENSE_FILE`).

### GitHub Action Inputs

The GitHub Action supports the following inputs:

| Input | Required | Default | Description |
|---------------|----------|-----------|-----------------------------------------------------------------------------------------------|
| `license` | Yes | | Posit Connect license file contents (store as a GitHub secret) |
| `version` | No | `release` | Posit Connect version |
| `image` | No | | Container image to use, including tag (e.g., `posit/connect:2025.12.0`). Overrides `version`. |
| `config-file` | No | | Path to rstudio-connect.gcfg configuration file |
| `port` | No | `3939` | Port to map the Connect container to |
| `quiet` | No | `false` | Suppress progress indicators during image pull |
| `env` | No | | Environment variables to pass to Docker container (one per line, format: KEY=VALUE) |
| `command` | Yes | | Command to run against Connect |

### Deploy a Connect Manifest

```yaml
name: Integration tests with Connect
on:
Expand Down Expand Up @@ -139,19 +159,7 @@ The `$CONNECT_API_KEY` and `$CONNECT_SERVER` environment variables are available
command: 'curl -f -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content'
```

### GitHub Action Options

The GitHub Action supports the following inputs:

- `license` (required): Posit Connect license key (store as a GitHub secret)
- `version` (optional): Posit Connect version (default: release)
- `config-file` (optional): Path to rstudio-connect.gcfg configuration file
- `port` (optional): Port to map the Connect container to (default: 3939)
- `quiet` (optional): Suppress progress indicators during image pull (default: false)
- `env` (optional): Environment variables to pass to Docker container (one per line, format: KEY=VALUE)
- `command` (required): Command to run against Connect

Example with environment variables:
### Set Environment Variables

```yaml
- name: Test deployment with custom env vars
Expand All @@ -165,6 +173,17 @@ Example with environment variables:
command: rsconnect deploy manifest .
```

### Specify a Custom Container Image

```yaml
- name: Test deployment with custom image
uses: posit-dev/with-connect@v1
with:
image: rstudio/rstudio-connect:jammy-2025.09.0
license: ${{ secrets.CONNECT_LICENSE_FILE }}
command: rsconnect deploy manifest .
```

## Minimum Version

Posit Connect 2022.10.0 or later is required. Earlier versions did not have the bootstrap endpoint used in this utility.
8 changes: 7 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: 'Deploy to Posit Connect'
description: 'Deploy content to Posit Connect using with-connect'
inputs:
license:
description: 'Posit Connect license key'
description: 'Posit Connect license file contents'
required: true
version:
description: 'Posit Connect version to use'
Expand All @@ -11,6 +11,9 @@ inputs:
config-file:
description: 'Path to optional rstudio-connect.gcfg configuration file'
required: false
image:
description: 'Container image to use for Posit Connect'
required: false
quiet:
description: 'Suppress progress indicators during image pull'
required: false
Expand Down Expand Up @@ -47,6 +50,9 @@ runs:
if [ -n "${{ inputs.config-file }}" ]; then
ARGS="$ARGS --config ${{ inputs.config-file }}"
fi
if [ -n "${{ inputs.image }}" ]; then
ARGS="$ARGS --image ${{ inputs.image }}"
fi
if [ "${{ inputs.quiet }}" = "true" ]; then
ARGS="$ARGS --quiet"
fi
Expand Down
44 changes: 36 additions & 8 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@


IMAGE = "rstudio/rstudio-connect"
VERSION = "release"


def parse_args():
"""
Parse command line arguments.

Handles the special -- separator to distinguish between tool arguments
and the command to run against Connect.
"""
Expand All @@ -27,14 +28,18 @@ def parse_args():
)
parser.add_argument(
"--version",
default="release",
help="Posit Connect version (default: release, the latest stable release)",
default=VERSION,
help=f"Posit Connect version (default: {VERSION}, the latest stable release)",
)
parser.add_argument(
"--license",
default="./rstudio-connect.lic",
help="Path to Posit Connect license file (default: ./rstudio-connect.lic)",
)
parser.add_argument(
"--image",
help="Container image to use, including tag (overrides --version)",
)
parser.add_argument(
"--config", help="Path to rstudio-connect.gcfg configuration file"
)
Expand Down Expand Up @@ -74,7 +79,7 @@ def parse_args():
def has_local_image(client, image_name: str) -> bool:
"""
Check if a Docker image exists in the local cache.

Used to avoid unnecessary pulls when the image is already available locally.
"""
try:
Expand Down Expand Up @@ -141,6 +146,20 @@ def ensure_image(client, base_image: str, tag: str, version: str, quiet: bool) -
raise RuntimeError(f"Failed to pull image and no local copy available: {e}")


def parse_image_spec(image: str) -> tuple[str, str, bool]:
"""
Parse an image specification into base image and tag.

Returns:
tuple[str, str, bool]: (base_image, tag, used_default_tag)
"""
try:
base_image, tag = image.rsplit(":", 1)
return (base_image, tag, False)
except ValueError:
return (image, "latest", True)


def get_docker_tag(version: str) -> tuple[str, str]:
"""
Convert a version string to the appropriate Docker image and tag.
Expand Down Expand Up @@ -185,7 +204,7 @@ def get_docker_tag(version: str) -> tuple[str, str]:
def main() -> int:
"""
Main entry point for the with-connect CLI tool.

Orchestrates the full workflow:
1. Parse arguments and validate file paths
2. Ensure Docker image is available
Expand All @@ -206,8 +225,17 @@ def main() -> int:
if not os.path.exists(config_path):
raise RuntimeError(f"Config file does not exist: {config_path}")

if args.image and args.version != VERSION:
raise RuntimeError("Cannot specify both 'image' and 'version'")

client = docker.from_env()
base_image, tag = get_docker_tag(args.version)

if args.image:
base_image, tag, used_default = parse_image_spec(args.image)
if used_default:
print(f"No tag specified for image '{args.image}'. Using default tag 'latest'.")
else:
base_image, tag = get_docker_tag(args.version)
image_name = f"{base_image}:{tag}"

bootstrap_secret = base64.b64encode(os.urandom(32)).decode("utf-8")
Expand Down Expand Up @@ -314,7 +342,7 @@ def is_port_open(host: str, port: int, timeout: float = 30.0) -> bool:
def extract_server_version(logs: str) -> str | None:
"""
Extract the Posit Connect version from container logs.

Looks for the startup message like 'Starting Posit Connect v2025.09.0'
and supports dev versions like 'v2025.11.0-dev+29-gd0db52662c'.
Returns None if version string not found.
Expand Down Expand Up @@ -365,7 +393,7 @@ def wait_for_http_server(
def get_api_key(bootstrap_secret: str, container, server_url: str) -> str:
"""
Bootstrap Connect and retrieve an API key.

Uses Connect's bootstrap endpoint with a JWT generated from the bootstrap
secret to create and retrieve an API key. This key is used to authenticate
commands run against the Connect instance. Requires Connect 2022.10.0+.
Expand Down
49 changes: 49 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,46 @@ def test_custom_port():
assert "default: 3939" in result.stdout


def test_image_and_version_exclusive():
with tempfile.NamedTemporaryFile(mode="w", suffix=".lic", delete=False) as f:
license_file = f.name

try:
result = subprocess.run(
[
sys.executable,
"main.py",
"--license",
license_file,
"--image",
"rstudio/rstudio-connect:jammy-2025.09.0",
"--version",
"2024.08.0",
],
capture_output=True,
text=True,
)

assert result.returncode == 1
assert "Cannot specify both 'image' and 'version'" in result.stderr
finally:
os.unlink(license_file)


def test_image_without_tag():
base_image, tag, used_default = main.parse_image_spec("rstudio/rstudio-connect")
assert base_image == "rstudio/rstudio-connect"
assert tag == "latest"
assert used_default is True


def test_image_with_tag():
base_image, tag, used_default = main.parse_image_spec("rstudio/rstudio-connect:jammy-2025.09.0")
assert base_image == "rstudio/rstudio-connect"
assert tag == "jammy-2025.09.0"
assert used_default is False


if __name__ == "__main__":
test_license_file_not_exists()
print("✓ test_license_file_not_exists passed")
Expand All @@ -202,6 +242,15 @@ def test_custom_port():
test_valid_license_http_server_starts()
print("✓ test_valid_license_http_server_starts passed")

test_image_and_version_exclusive()
print("✓ test_image_and_version_exclusive passed")

test_image_without_tag()
print("✓ test_image_without_tag passed")

test_image_with_tag()
print("✓ test_image_with_tag passed")

test_get_docker_tag_latest()
print("✓ test_get_docker_tag_latest passed")

Expand Down