From f839f684adcfc7594b9131eb7c76cca96f01fed4 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Mon, 1 Dec 2025 12:14:18 -0600 Subject: [PATCH 1/2] Add image parameter This change allows specifying a custom container image for Posit Connect --- README.md | 55 +++++++++++++++++++++++++++++++++----------------- action.yml | 8 +++++++- main.py | 36 ++++++++++++++++++++++++--------- test_main.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index aee8bb3..a2370c4 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: @@ -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 @@ -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. diff --git a/action.yml b/action.yml index 98614b9..0cd0ebb 100644 --- a/action.yml +++ b/action.yml @@ -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' @@ -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 @@ -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 diff --git a/main.py b/main.py index aaa66b8..3e963b0 100644 --- a/main.py +++ b/main.py @@ -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. """ @@ -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" ) @@ -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: @@ -185,7 +190,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 @@ -206,9 +211,22 @@ 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) - image_name = f"{base_image}:{tag}" + + if args.image: + try: + base_image, tag = args.image.rsplit(":", 1) + except ValueError: + raise RuntimeError( + f"Invalid image format: '{args.image}'. Image must include a tag (e.g., rstudio/rstudio-connect:2025.09.0)" + ) + image_name = args.image + 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") @@ -314,7 +332,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. @@ -365,7 +383,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+. diff --git a/test_main.py b/test_main.py index 8ad412a..2cc66af 100644 --- a/test_main.py +++ b/test_main.py @@ -186,6 +186,57 @@ 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(): + 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", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "Invalid image format" in result.stderr + assert "Image must include a tag" in result.stderr + finally: + os.unlink(license_file) + + if __name__ == "__main__": test_license_file_not_exists() print("✓ test_license_file_not_exists passed") @@ -202,6 +253,12 @@ 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_get_docker_tag_latest() print("✓ test_get_docker_tag_latest passed") From 4406fd8bc3f5808a43c1b1a356457dd1b4fd990d Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Fri, 19 Dec 2025 09:33:52 -0600 Subject: [PATCH 2/2] Set tag to `latest` if no tag set in `image` param This also moves the logic into a helper function to make it easier to test. --- main.py | 28 +++++++++++++++++++--------- test_main.py | 34 +++++++++++++--------------------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/main.py b/main.py index 3e963b0..4b357b1 100644 --- a/main.py +++ b/main.py @@ -146,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. @@ -212,21 +226,17 @@ def main() -> int: 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") + raise RuntimeError("Cannot specify both 'image' and 'version'") client = docker.from_env() if args.image: - try: - base_image, tag = args.image.rsplit(":", 1) - except ValueError: - raise RuntimeError( - f"Invalid image format: '{args.image}'. Image must include a tag (e.g., rstudio/rstudio-connect:2025.09.0)" - ) - image_name = 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}" + image_name = f"{base_image}:{tag}" bootstrap_secret = base64.b64encode(os.urandom(32)).decode("utf-8") diff --git a/test_main.py b/test_main.py index 2cc66af..d77cfaf 100644 --- a/test_main.py +++ b/test_main.py @@ -207,34 +207,23 @@ def test_image_and_version_exclusive(): ) assert result.returncode == 1 - assert "Cannot specify both --image and --version" in result.stderr + assert "Cannot specify both 'image' and 'version'" in result.stderr finally: os.unlink(license_file) def test_image_without_tag(): - with tempfile.NamedTemporaryFile(mode="w", suffix=".lic", delete=False) as f: - license_file = f.name + 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 - try: - result = subprocess.run( - [ - sys.executable, - "main.py", - "--license", - license_file, - "--image", - "rstudio/rstudio-connect", - ], - capture_output=True, - text=True, - ) - assert result.returncode == 1 - assert "Invalid image format" in result.stderr - assert "Image must include a tag" in result.stderr - finally: - os.unlink(license_file) +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__": @@ -259,6 +248,9 @@ def test_image_without_tag(): 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")