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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,60 @@ jobs:
[ "$TEST_STRING" = "This contains single quotes" ] || exit 1
echo "✓ Multiline test passed - variables, single quotes, and double quotes all work"

test-action-start-only:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Start Connect (no command)
id: start-connect
uses: ./
with:
version: 2024.08.0
license: ${{ secrets.CONNECT_LICENSE }}

- name: Verify outputs are set
run: |
set -euo pipefail
echo "API Key length: ${#CONNECT_API_KEY}"
echo "Server: $CONNECT_SERVER"
echo "Container ID: $CONTAINER_ID"
[ -n "$CONNECT_API_KEY" ] || { echo "ERROR: CONNECT_API_KEY output is empty"; exit 1; }
[ -n "$CONNECT_SERVER" ] || { echo "ERROR: CONNECT_SERVER output is empty"; exit 1; }
[ -n "$CONTAINER_ID" ] || { echo "ERROR: CONTAINER_ID output is empty"; exit 1; }
echo "✓ All outputs are set"
env:
CONNECT_API_KEY: ${{ steps.start-connect.outputs.CONNECT_API_KEY }}
CONNECT_SERVER: ${{ steps.start-connect.outputs.CONNECT_SERVER }}
CONTAINER_ID: ${{ steps.start-connect.outputs.CONTAINER_ID }}

- name: Use Connect with outputs
run: |
set -euo pipefail
RESPONSE=$(curl -f -H "Authorization: Key $CONNECT_API_KEY" "$CONNECT_SERVER/__api__/v1/content")
echo "Response: $RESPONSE"
[ "$RESPONSE" = "[]" ] || exit 1
echo "✓ Successfully used Connect with action outputs"
env:
CONNECT_API_KEY: ${{ steps.start-connect.outputs.CONNECT_API_KEY }}
CONNECT_SERVER: ${{ steps.start-connect.outputs.CONNECT_SERVER }}

- name: Stop Connect
uses: ./
with:
stop: ${{ steps.start-connect.outputs.CONTAINER_ID }}

- name: Verify container stopped
run: |
set -euo pipefail
if docker ps -q --filter "id=$CONTAINER_ID" | grep -q .; then
echo "ERROR: Container is still running"
exit 1
fi
echo "✓ Container successfully stopped"
env:
CONTAINER_ID: ${{ steps.start-connect.outputs.CONTAINER_ID }}

test-cli:
runs-on: ubuntu-latest
steps:
Expand Down
78 changes: 77 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Without `bash -c`, the environment variables would be evaluated before `with-con
| `--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. |
| `--stop` | | Stop a running Connect container by ID, or use `CONTAINER_ID` env var if not specified. |

Example:

Expand All @@ -86,6 +87,30 @@ You can use this to override Connect server configuration by passing in `CONNECT

If you need env vars that are useful for the command running after `--`, just set them in the environment from which you call `with-connect`: the command will inherit that environment.

### Start-Only Mode

If you omit the command after `--`, Connect will start and remain running. The tool outputs shell variables you can use to interact with Connect:

```bash
with-connect --license ./rstudio-connect.lic
# Outputs:
# CONNECT_API_KEY=...
# CONNECT_SERVER=http://localhost:3939
# CONTAINER_ID=...
```

You can eval the output to set the variables in your shell:

```bash
eval $(with-connect --license ./rstudio-connect.lic)
curl -H "Authorization: Key $CONNECT_API_KEY" $CONNECT_SERVER/__api__/v1/content

# Stop Connect when done (--stop without argument uses $CONTAINER_ID)
with-connect --stop
```

This is useful when you need to run multiple commands or use other tools against the running Connect instance.

## GitHub Actions

This project contains a GitHub Action for use in CI/CD workflows. Use the `@main` tag to reference the action.
Expand All @@ -105,7 +130,18 @@ The GitHub Action supports the following inputs:
| `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 |
| `command` | No | | Command to run against Connect (omit for start-only mode) |
| `stop` | No | | Container ID to stop (use instead of starting a new container) |

### GitHub Action Outputs

When no `command` is provided (start-only mode), the action sets these outputs:

| Output | Description |
|-------------------|------------------------------------------|
| `CONNECT_API_KEY` | Connect API key for authentication |
| `CONNECT_SERVER` | Connect server URL (e.g., `http://localhost:3939`) |
| `CONTAINER_ID` | Docker container ID (use with `stop` input to stop the container) |

### Deploy a Connect Manifest

Expand Down Expand Up @@ -184,6 +220,46 @@ The `$CONNECT_API_KEY` and `$CONNECT_SERVER` environment variables are available
command: rsconnect deploy manifest .
```

### Multi-Step Workflows (Start-Only Mode)

For workflows that need to run multiple steps against Connect, or use other actions with the running instance, use start-only mode:

```yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

# Start Connect without a command - it will keep running
- name: Start Connect
id: connect
uses: posit-dev/with-connect@main
with:
version: 2025.09.0
license: ${{ secrets.CONNECT_LICENSE_FILE }}

# Use the outputs in subsequent steps
- name: Deploy content
run: rsconnect deploy manifest .
env:
CONNECT_API_KEY: ${{ steps.connect.outputs.CONNECT_API_KEY }}
CONNECT_SERVER: ${{ steps.connect.outputs.CONNECT_SERVER }}

# Use another action with Connect
- name: Run integration tests
uses: some-other-action@v1
with:
connect-url: ${{ steps.connect.outputs.CONNECT_SERVER }}
api-key: ${{ steps.connect.outputs.CONNECT_API_KEY }}

# Stop Connect when done
- name: Stop Connect
uses: posit-dev/with-connect@main
with:
stop: ${{ steps.connect.outputs.CONTAINER_ID }}
```

## Minimum Version

Posit Connect 2022.10.0 or later is required. Earlier versions did not have the bootstrap endpoint used in this utility.
36 changes: 33 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,22 @@ inputs:
description: 'Environment variables to pass to Docker container (one per line, format: KEY=VALUE)'
required: false
command:
description: 'Command to run against Connect'
required: true
description: 'Command to run against Connect (omit to start Connect and output credentials)'
required: false
stop:
description: 'Container ID to stop (use instead of starting a new container)'
required: false

outputs:
CONNECT_API_KEY:
description: 'Connect API key (only set when no command is provided)'
value: ${{ steps.run.outputs.CONNECT_API_KEY }}
CONNECT_SERVER:
description: 'Connect server URL (only set when no command is provided)'
value: ${{ steps.run.outputs.CONNECT_SERVER }}
CONTAINER_ID:
description: 'Docker container ID (only set when no command is provided)'
value: ${{ steps.run.outputs.CONTAINER_ID }}

runs:
using: composite
Expand All @@ -44,8 +58,16 @@ runs:
run: echo "${{ inputs.license }}" > rstudio-connect.lic

- name: Run command
id: run
shell: bash
run: |
# Handle stop mode
if [ -n "${{ inputs.stop }}" ]; then
with-connect --stop "${{ inputs.stop }}"
exit 0
fi

# Build arguments
ARGS="--version ${{ inputs.version }} --port ${{ inputs.port }}"
if [ -n "${{ inputs.config-file }}" ]; then
ARGS="$ARGS --config ${{ inputs.config-file }}"
Expand All @@ -63,6 +85,14 @@ runs:
fi
done <<< "${{ inputs.env }}"
fi
with-connect $ARGS -- bash <<'SCRIPT'

# Check if command is provided
if [ -z "${{ inputs.command }}" ]; then
# Start-only mode: output is already in KEY=value format
with-connect $ARGS >> $GITHUB_OUTPUT
else
# Run with command
with-connect $ARGS -- bash <<'SCRIPT'
${{ inputs.command }}
SCRIPT
fi
Comment on lines 89 to 98
Copy link
Contributor

Choose a reason for hiding this comment

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

nit; is there a way to print these as bash variables so that one doesn't have to use jq to parse it.

Something like...

with-connect $ARGS | with-connect -- bash <<'SCRIPT'
    ${{ inputs.command }}
SCRIPT

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, I did that, have a look and see what you think

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! Looks great to me. Thanks!

78 changes: 54 additions & 24 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ def parse_args():
default=3939,
help="Port to map the Connect container to (default: 3939)",
)
parser.add_argument(
"--stop",
nargs="?",
default=None,
const="", # sentinel for --stop without argument
metavar="CONTAINER_ID",
help="Stop a running Connect container by ID (uses CONTAINER_ID env var if not specified)",
)

# Handle -- separator and capture remaining args
if "--" in sys.argv:
Expand Down Expand Up @@ -99,9 +107,9 @@ def pull_image(client, base_image: str, tag: str, quiet: bool) -> None:
image_name = f"{base_image}:{tag}"

if quiet:
print(f"Pulling image {image_name}...")
print(f"Pulling image {image_name}...", file=sys.stderr)
else:
print(f"Pulling image {image_name}...", end="", flush=True)
print(f"Pulling image {image_name}...", end="", flush=True, file=sys.stderr)

pull_stream = client.api.pull(
base_image, tag=tag, platform="linux/amd64", stream=True, decode=True
Expand All @@ -112,12 +120,12 @@ def pull_image(client, base_image: str, tag: str, quiet: bool) -> None:
if "status" in chunk:
dot_count += 1
if dot_count % 10 == 0 and not quiet:
print(".", end="", flush=True)
print(".", end="", flush=True, file=sys.stderr)

if not quiet:
print()
print(file=sys.stderr)

print(f"Successfully pulled {image_name}")
print(f"Successfully pulled {image_name}", file=sys.stderr)


def ensure_image(client, base_image: str, tag: str, version: str, quiet: bool) -> None:
Expand All @@ -134,14 +142,14 @@ def ensure_image(client, base_image: str, tag: str, version: str, quiet: bool) -
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}")
print(f"Using locally cached image {image_name}", file=sys.stderr)
return

try:
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}")
print(f"Pull failed, but using locally cached image {image_name}", file=sys.stderr)
else:
raise RuntimeError(f"Failed to pull image and no local copy available: {e}")

Expand Down Expand Up @@ -216,6 +224,20 @@ def main() -> int:
"""
args = parse_args()

# Handle --stop mode: just stop the container and exit
if args.stop is not None:
container_id = args.stop or os.environ.get("CONTAINER_ID")
if not container_id:
raise RuntimeError("No container ID provided and CONTAINER_ID environment variable not set")
client = docker.from_env()
try:
container = client.containers.get(container_id)
container.stop()
print(f"Stopped container {container_id}", file=sys.stderr)
return 0
except docker.errors.NotFound:
raise RuntimeError(f"Container not found: {container_id}")

license_path = os.path.abspath(os.path.expanduser(args.license))
if not os.path.exists(license_path):
raise RuntimeError(f"License file does not exist: {license_path}")
Expand All @@ -233,7 +255,7 @@ def main() -> int:
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'.")
print(f"No tag specified for image '{args.image}'. Using default tag 'latest'.", file=sys.stderr)
else:
base_image, tag = get_docker_tag(args.version)
image_name = f"{base_image}:{tag}"
Expand Down Expand Up @@ -286,18 +308,19 @@ def main() -> int:
)

server_url = f"http://localhost:{args.port}"
stop_container = True

try:
print(f"Waiting for port {args.port} to open...")
print(f"Waiting for port {args.port} to open...", file=sys.stderr)
if not is_port_open("localhost", args.port, timeout=60.0):
print("\nContainer logs:")
print(container.logs().decode("utf-8", errors="replace"))
print("\nContainer logs:", file=sys.stderr)
print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr)
raise RuntimeError("Posit Connect did not start within 60 seconds.")

print("Waiting for HTTP server to start...")
print("Waiting for HTTP server to start...", file=sys.stderr)
if not wait_for_http_server(container, timeout=60.0, poll_interval=2.0):
print("\nContainer logs:")
print(container.logs().decode("utf-8", errors="replace"))
print("\nContainer logs:", file=sys.stderr)
print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr)
raise RuntimeError(
"Posit Connect did not log HTTP server start within 60 seconds."
)
Expand All @@ -317,10 +340,17 @@ def main() -> int:
exit_code = result.returncode
except subprocess.CalledProcessError as e:
exit_code = e.returncode
else:
# Start-only mode: output credentials and keep container running
print(f"CONNECT_API_KEY={api_key}")
print(f"CONNECT_SERVER={server_url}")
print(f"CONTAINER_ID={container.id}")
stop_container = False

return exit_code
finally:
container.stop()
if stop_container:
container.stop()


def is_port_open(host: str, port: int, timeout: float = 30.0) -> bool:
Expand Down Expand Up @@ -374,11 +404,11 @@ def wait_for_http_server(
if not version:
version = extract_server_version(logs)
if version:
print(f"Running Posit Connect v{version}")
print(f"Running Posit Connect v{version}", file=sys.stderr)

if "Unable to obtain a valid license" in logs:
print("\nContainer logs:")
print(logs)
print("\nContainer logs:", file=sys.stderr)
print(logs, file=sys.stderr)
container.stop()
raise RuntimeError(
"Unable to obtain a valid license. Your Posit Connect license may be expired or invalid. Please check your license file."
Expand Down Expand Up @@ -416,17 +446,17 @@ def get_api_key(bootstrap_secret: str, container, server_url: str) -> str:
if response and "api_key" in response:
api_key = response["api_key"]
if not api_key:
print("\nContainer logs:")
print(container.logs().decode("utf-8", errors="replace"))
print("\nContainer logs:", file=sys.stderr)
print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr)
raise RuntimeError("Bootstrap succeeded but returned empty API key")
return api_key
else:
print("\nContainer logs:")
print(container.logs().decode("utf-8", errors="replace"))
print("\nContainer logs:", file=sys.stderr)
print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr)
raise RuntimeError(f"Bootstrap returned unexpected response: {response}")
except Exception as e:
print("\nContainer logs:")
print(container.logs().decode("utf-8", errors="replace"))
print("\nContainer logs:", file=sys.stderr)
print(container.logs().decode("utf-8", errors="replace"), file=sys.stderr)
raise RuntimeError(f"Failed to bootstrap Connect and retrieve API key: {e}")


Expand Down
Loading