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..4b357b1 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: @@ -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. @@ -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 @@ -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") @@ -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. @@ -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+. diff --git a/test_main.py b/test_main.py index 8ad412a..d77cfaf 100644 --- a/test_main.py +++ b/test_main.py @@ -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") @@ -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")