From 52af8c7c41aa4845c3648b4431bc85cf0476358d Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 10 Dec 2025 16:13:34 -0500 Subject: [PATCH 1/2] feat: add preview alias for nightly builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "preview" version alias that maps to the nightly preview build (rstudio/rstudio-connect-preview:jammy-daily). This allows users to easily run the latest daily build without specifying the full image name. The "preview" alias follows the same always-pull behavior as "latest" and "release" to ensure users always get the newest nightly build. Also refactored get_docker_tag() to return a tuple of (base_image, tag) instead of just the tag, enabling support for different base images. Fixes #28 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- main.py | 57 +++++++++++++++++++++++++++++++--------------------- test_main.py | 48 +++++++++++++++++++++++++++++-------------- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/main.py b/main.py index 23d5ea3..aaa66b8 100644 --- a/main.py +++ b/main.py @@ -84,20 +84,22 @@ def has_local_image(client, image_name: str) -> bool: return False -def pull_image(client, image_name: str, tag: str, quiet: bool) -> None: +def pull_image(client, base_image: str, tag: str, quiet: bool) -> None: """ Pull a Docker image from the registry. - + Displays progress indicators (dots) unless quiet mode is enabled. Always pulls for linux/amd64 platform for ARM compatibility. """ + image_name = f"{base_image}:{tag}" + if quiet: print(f"Pulling image {image_name}...") else: print(f"Pulling image {image_name}...", end="", flush=True) pull_stream = client.api.pull( - IMAGE, tag=tag, platform="linux/amd64", stream=True, decode=True + base_image, tag=tag, platform="linux/amd64", stream=True, decode=True ) dot_count = 0 @@ -113,24 +115,25 @@ def pull_image(client, image_name: str, tag: str, quiet: bool) -> None: print(f"Successfully pulled {image_name}") -def ensure_image(client, image_name: str, tag: str, version: str, quiet: bool) -> None: +def ensure_image(client, base_image: str, tag: str, version: str, quiet: bool) -> None: """ Ensure the required Docker image is available. - + Strategy: - - For 'latest'/'release': always pull to get the newest version + - For 'latest'/'release'/'preview': always pull to get the newest version - For specific versions: use local cache if available - If pull fails: fall back to local cache if it exists - This allows offline usage with cached images """ - is_release = version in ("latest", "release") - + image_name = f"{base_image}:{tag}" + is_release = version in ("latest", "release", "preview") + if not is_release and has_local_image(client, image_name): print(f"Using locally cached image {image_name}") return - + try: - pull_image(client, image_name, tag, quiet) + pull_image(client, base_image, tag, quiet) except Exception as e: if has_local_image(client, image_name): print(f"Pull failed, but using locally cached image {image_name}") @@ -138,37 +141,45 @@ def ensure_image(client, image_name: str, tag: str, version: str, quiet: bool) - raise RuntimeError(f"Failed to pull image and no local copy available: {e}") -def get_docker_tag(version: str) -> str: +def get_docker_tag(version: str) -> tuple[str, str]: """ - Convert a version string to the appropriate Docker tag. - - Maps semantic versions to the correct base image tag based on when + Convert a version string to the appropriate Docker image and tag. + + Maps semantic versions to the correct base image and tag based on when Connect switched from bionic (Ubuntu 18.04) to jammy (Ubuntu 22.04). Also maps 'latest'/'release' to 'jammy' since 'latest' is unmaintained. + Maps 'preview' to the nightly build image. + + Returns: + tuple[str, str]: (base_image, tag) """ + if version == "preview": + # Map to nightly preview build + return ("rstudio/rstudio-connect-preview", "jammy-daily") + if version in ("latest", "release"): # For the rstudio/rstudio-connect image, "jammy" is currently used # for the latest stable release. "latest" never gets updated and points # to 2022.08.0, which, aside from being misleading, also does not # have the bootstrap endpoint that this utility relies on. - return "jammy" + return (IMAGE, "jammy") parts = version.split(".") if len(parts) < 2: - return version + return (IMAGE, version) try: year = int(parts[0]) month = int(parts[1]) except ValueError: - return version + return (IMAGE, version) if year > 2023 or (year == 2023 and month > 6): - return f"jammy-{version}" + return (IMAGE, f"jammy-{version}") elif year > 2022 or (year == 2022 and month >= 9): - return f"bionic-{version}" + return (IMAGE, f"bionic-{version}") else: - return version + return (IMAGE, version) def main() -> int: @@ -196,12 +207,12 @@ def main() -> int: raise RuntimeError(f"Config file does not exist: {config_path}") client = docker.from_env() - tag = get_docker_tag(args.version) - image_name = f"{IMAGE}:{tag}" + base_image, tag = get_docker_tag(args.version) + image_name = f"{base_image}:{tag}" bootstrap_secret = base64.b64encode(os.urandom(32)).decode("utf-8") - ensure_image(client, image_name, tag, args.version, args.quiet) + ensure_image(client, base_image, tag, args.version, args.quiet) mounts = [ docker.types.services.Mount( diff --git a/test_main.py b/test_main.py index c5732f2..8ad412a 100644 --- a/test_main.py +++ b/test_main.py @@ -82,33 +82,37 @@ def test_valid_license_http_server_starts(): def test_get_docker_tag_latest(): - assert main.get_docker_tag("latest") == "jammy" + assert main.get_docker_tag("latest") == ("rstudio/rstudio-connect", "jammy") def test_get_docker_tag_release(): - assert main.get_docker_tag("release") == "jammy" + assert main.get_docker_tag("release") == ("rstudio/rstudio-connect", "jammy") + + +def test_get_docker_tag_preview(): + assert main.get_docker_tag("preview") == ("rstudio/rstudio-connect-preview", "jammy-daily") def test_get_docker_tag_jammy_version(): - assert main.get_docker_tag("2025.09.0") == "jammy-2025.09.0" - assert main.get_docker_tag("2024.01.0") == "jammy-2024.01.0" - assert main.get_docker_tag("2023.07.0") == "jammy-2023.07.0" + assert main.get_docker_tag("2025.09.0") == ("rstudio/rstudio-connect", "jammy-2025.09.0") + assert main.get_docker_tag("2024.01.0") == ("rstudio/rstudio-connect", "jammy-2024.01.0") + assert main.get_docker_tag("2023.07.0") == ("rstudio/rstudio-connect", "jammy-2023.07.0") def test_get_docker_tag_bionic_version(): - assert main.get_docker_tag("2023.06.0") == "bionic-2023.06.0" - assert main.get_docker_tag("2023.01.0") == "bionic-2023.01.0" - assert main.get_docker_tag("2022.09.0") == "bionic-2022.09.0" + assert main.get_docker_tag("2023.06.0") == ("rstudio/rstudio-connect", "bionic-2023.06.0") + assert main.get_docker_tag("2023.01.0") == ("rstudio/rstudio-connect", "bionic-2023.01.0") + assert main.get_docker_tag("2022.09.0") == ("rstudio/rstudio-connect", "bionic-2022.09.0") def test_get_docker_tag_old_version(): - assert main.get_docker_tag("2022.08.0") == "2022.08.0" - assert main.get_docker_tag("2021.12.0") == "2021.12.0" + assert main.get_docker_tag("2022.08.0") == ("rstudio/rstudio-connect", "2022.08.0") + assert main.get_docker_tag("2021.12.0") == ("rstudio/rstudio-connect", "2021.12.0") def test_get_docker_tag_invalid_format(): - assert main.get_docker_tag("jammy") == "jammy" - assert main.get_docker_tag("custom-tag") == "custom-tag" + assert main.get_docker_tag("jammy") == ("rstudio/rstudio-connect", "jammy") + assert main.get_docker_tag("custom-tag") == ("rstudio/rstudio-connect", "custom-tag") def test_extract_server_version(): @@ -143,8 +147,8 @@ def test_local_image_usage(): mock_image = MagicMock() mock_client.images.get.return_value = mock_image - tag = main.get_docker_tag(mock_args.version) - image_name = f"{main.IMAGE}:{tag}" + base_image, tag = main.get_docker_tag(mock_args.version) + image_name = f"{base_image}:{tag}" try: mock_client.images.get(image_name) @@ -159,7 +163,15 @@ def test_release_always_pulls(): mock_args = Mock() mock_args.version = "release" - should_pull = mock_args.version in ("latest", "release") + should_pull = mock_args.version in ("latest", "release", "preview") + assert should_pull is True + + +def test_preview_always_pulls(): + mock_args = Mock() + mock_args.version = "preview" + + should_pull = mock_args.version in ("latest", "release", "preview") assert should_pull is True @@ -196,6 +208,9 @@ def test_custom_port(): test_get_docker_tag_release() print("✓ test_get_docker_tag_release passed") + test_get_docker_tag_preview() + print("✓ test_get_docker_tag_preview passed") + test_get_docker_tag_jammy_version() print("✓ test_get_docker_tag_jammy_version passed") @@ -226,6 +241,9 @@ def test_custom_port(): test_release_always_pulls() print("✓ test_release_always_pulls passed") + test_preview_always_pulls() + print("✓ test_preview_always_pulls passed") + test_custom_port() print("✓ test_custom_port passed") From 01b23a16dccfd4cd719512a1d5d5d74aed14a250 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 10 Dec 2025 16:57:15 -0500 Subject: [PATCH 2/2] Fix quoting --- .github/workflows/ci.yml | 4 ++-- action.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f80845..85d67ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: # would evaluate before with-connect defines it. command: 'curl -f -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content' - - name: Test with-connect action (multiline) + - name: Test with-connect action (multiline, different quoting) uses: ./ with: version: 2024.08.0 @@ -46,7 +46,7 @@ jobs: command: | echo "Testing multiline command support" curl -f -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content - echo "Multiline test passed" + echo 'Multiline test passed' test-cli: runs-on: ubuntu-latest diff --git a/action.yml b/action.yml index 0fb5469..8c1103f 100644 --- a/action.yml +++ b/action.yml @@ -57,4 +57,4 @@ runs: fi done <<< "${{ inputs.env }}" fi - with-connect $ARGS -- bash -c '${{ inputs.command }}' + with-connect $ARGS -- bash -c "$1" _ "${{ inputs.command }}"