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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ 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
license: ${{ secrets.CONNECT_LICENSE }}
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
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having trouble understanding the purpose of the _ argument. Are you ignoring some positional argument? If so, should that argument be a --variable instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand either, but from the tests I can confirm that it supports multiline commands and is robust to the inclusion of " and ' in the command, which the version with straight quotes was not.

When Claude made this change, it said:

This uses the pattern bash -c "$1" _ "$command" where:
  - The command string is passed as a positional parameter to bash -c
  - _ sets $0 (command name)
  - The command becomes $1 and is executed

  This avoids all quote escaping issues since the command is passed as a separate argument rather than embedded in
  a quoted string.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out this doesn't actually work! And the test-action workflow doesn't actually assert its output. Working on a fix for the fix now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed for reals in #38

57 changes: 34 additions & 23 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -113,62 +115,71 @@ 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}")
else:
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:
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 33 additions & 15 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down