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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

GitHub Actions monorepo (`posit-dev/connect-actions`) providing composite actions for deploying Python applications to [Posit Connect](https://posit.co/products/enterprise/connect/) from GitHub Actions. Currently in early beta; only Python app types supported. All Connect interaction goes through the [`posit` CLI](https://github.com/posit-dev/posit-cli), which mounts the full `rsconnect-python` command set under `posit connect` and adds a `gh api`-style `posit connect api` raw REST client.
GitHub Actions monorepo (`posit-dev/connect-actions`) providing composite actions for deploying applications to [Posit Connect](https://posit.co/products/enterprise/connect/) from GitHub Actions. Currently in early beta; Python, Quarto, and Node.js content deploys from source, and R content deploys from a pre-built `manifest.json`. All Connect interaction goes through the [`posit` CLI](https://github.com/posit-dev/posit-cli), which mounts the full `rsconnect-python` command set under `posit connect` and adds a `gh api`-style `posit connect api` raw REST client.

## Repository Structure

Expand Down Expand Up @@ -35,10 +35,10 @@ Both actions authenticate once via `scripts/login.sh`, which logs the `posit` CL
1. Install `uv` and the `posit` CLI
2. Resolve config (server, GUID, entrypoint, extra_files) via `connect_actions.cli resolve-config`
3. Log in to Connect (`scripts/login.sh`)
4. Determine app type: if a `manifest.json` is present use `manifest`; otherwise query `app_mode` from the Connect content record (`posit connect api`) and run `connect_actions.cli resolve-app-type`, which maps it to a `posit connect deploy` subcommand (shiny, fastapi, flask, dash, streamlit, bokeh, quarto) and sets `needs_quarto` (true only when the resolved subcommand is `quarto`). R app modes (`shiny`, `rmd-shiny`, `rmd-static`, `api`) require a `manifest.json` and will error clearly if it is not present.
4. Determine app type: if a `manifest.json` is present use `manifest`; otherwise query `app_mode` from the Connect content record (`posit connect api`) and run `connect_actions.cli resolve-app-type`, which maps it to a `posit connect deploy` subcommand (shiny, fastapi, flask, dash, streamlit, bokeh, quarto), sets `needs_quarto` (true only when the resolved subcommand is `quarto`), and sets `needs_requirements` (true only for content with Python dependencies: the mapped subcommands plus fall-through `python-*`/`jupyter-*` app modes; false for e.g. `nodejs` and for manifests). R app modes (`shiny`, `rmd-shiny`, `rmd-static`, `api`) require a `manifest.json` and will error clearly if it is not present.
5. Set up Quarto (`quarto-dev/quarto-actions/setup`) only when `needs_quarto` is true — the `quarto` subcommand runs `quarto inspect` locally to build the manifest
6. Check Connect capabilities: read the server version (`posit connect api server_settings -q .version`) and run `connect_actions.cli check-deploy-features`, which fails fast if a draft is requested on a server older than 2025.07.0 and sets the `send_metadata` output (false on servers older than 2025.12.0, or when the version can't be read)
7. Generate `requirements.txt` from `pyproject.toml` if missing (`generate-requirements.sh`)
7. Generate `requirements.txt` if missing (`generate-requirements.sh`, from `uv.lock` or `pyproject.toml`) — only when `needs_requirements` is true; non-Python content (e.g. Node.js) skips it
8. Run `posit connect deploy` with the resolved app type, `--draft` for PRs, passing `--metadata` only when `send_metadata` is true, and appending `extra_files` as trailing positionals for `quarto` deploys
9. Extract content URL from deploy logs, set as action output
10. On PRs: comment preview URL via `actions/github-script`
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ There are a few prerequisites to set up before you can use these actions:

1. Deploy your content to Connect for the first time by other means. The `deploy` action here will not create a new content item for you; it will only update an existing one with new code. If you use the Publisher extension for Positron, VS Code, or other Code OSS forks, check in the `.posit/` TOML files it creates---this action can detect and use them. Otherwise, you just need the content's URL.
2. Configure auth. If your Connect server is version 2026.07.0 or newer and has an Enhanced or Advanced license, we recommend using the Trusted Publishing feature, which allows you to publish from this GitHub repository automatically, no API keys needed. You can enable this in the "Access" tab of the content settings. If you are not using Trusted Publishing, you will need to get an API key with at least "publisher" privileges from your Connect account and add it as a GitHub Actions secret.
3. Make sure your requirements files are checked in. For Python content, this can either be a `uv.lock` file or a `requirements.txt`, and if you have neither, one can be generated from a `pyproject.toml` file. (We recommend that you keep both `pyproject.toml` and one of those lockfiles and use [Dependabot](https://docs.github.com/en/code-security/dependabot) to update the lockfile on a schedule so that your content stays up to date and security vulnerabilities are resolved.) For R, use the `rsconnect::writeManifest()` function to generate a `manifest.json` file.
3. Make sure the files declaring your dependencies are checked in.
* For Python content, this can either be a `uv.lock` file or a `requirements.txt`, and if you have neither, one can be generated from a `pyproject.toml` file. (We recommend that you keep both `pyproject.toml` and one of those lockfiles and use [Dependabot](https://docs.github.com/en/code-security/dependabot) to update the lockfile on a schedule so that your content stays up to date and security vulnerabilities are resolved.)
* For R content, use the `rsconnect::writeManifest()` function to generate a `manifest.json` file.
* For Node.js content, your `package.json` and `package-lock.json` declare your dependencies.

Then, you can add these actions. There are examples below, or you can let an AI agent set them up for you with the bundled Agent Skill.

Expand Down Expand Up @@ -93,6 +96,8 @@ For authentication, we recommend using Trusted Publishing if your Connect server

If a `manifest.json` exists at the root of your repo, the action deploys it directly using `posit connect deploy manifest`. In this mode the manifest's declared app type, entrypoint, and dependencies are used as-is.

Requirements generation only applies to content with Python dependencies (the Python frameworks, plus Quarto, which may run Python via the jupyter engine). Content without them---such as Node.js apps, whose dependencies come from `package.json`/`package-lock.json`---skips this step entirely.

For Python content, Connect installs your app's dependencies from a `requirements.txt`. When one isn't present, the action generates it, looking for a dependency source in this order:

1. **`requirements.txt`** -- if it already exists, it is used as-is.
Expand Down
5 changes: 5 additions & 0 deletions deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ runs:
env:
DRAFT: ${{ inputs.draft }}

# Python content declares its dependencies through a requirements.txt,
# generated here from uv.lock or pyproject.toml when absent. Non-Python
# content (e.g. Node.js apps) has no Python dependency source at all, so
# skip the step rather than failing on a legitimately missing one.
- name: Generate requirements.txt if needed
if: steps.apptype.outputs.needs_requirements == 'true'
shell: bash
working-directory: ${{ inputs.path }}
run: ${{ github.action_path }}/scripts/generate-requirements.sh
Expand Down
5 changes: 4 additions & 1 deletion deploy/scripts/generate-requirements.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#!/bin/bash
# Generate requirements.txt if it doesn't exist
# Generate requirements.txt if it doesn't exist.
# The action only runs this step when resolve-app-type reports
# needs_requirements (content with Python dependencies), so the manifest check
# below is redundant on that path; it is kept for direct invocations.

set -euo pipefail

Expand Down
3 changes: 3 additions & 0 deletions skills/setup-connect-deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ Connect needs to know your app's dependencies. Check the app directory:
- Recommend committing a lockfile (`uv.lock` via `uv lock`, or a pinned
`requirements.txt` via `uv pip compile pyproject.toml -o requirements.txt`)
for reproducible deploys, optionally kept fresh with Dependabot.
- **Node.js, no manifest** → nothing to check; dependencies come from the app's
`package.json`/`package-lock.json` and the action skips requirements
generation for non-Python content.

Don't generate these files yourself unless the user asks — just report what you
found and recommend.
Expand Down
30 changes: 26 additions & 4 deletions src/connect_actions/apptype.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@
}
)

# Deploy types whose bundles declare Python dependencies through a
# requirements.txt: every mapped type qualifies -- the Python frameworks
# obviously, and ``quarto`` because a Quarto doc may run Python via the jupyter
# engine. Fall-through modes are covered by prefix in :func:`resolve_app_type`:
# any ``python-*``/``jupyter-*`` app_mode is Python content even without an
# entry in ``APP_MODE_TO_TYPE``. Everything else (e.g. ``nodejs``) carries no
# Python dependencies, so the action skips requirements generation instead of
# failing on a bundle that legitimately has no dependency source.
REQUIREMENTS_DEPLOY_TYPES: frozenset[str] = frozenset(APP_MODE_TO_TYPE.values())


class AppTypeError(Exception):
"""Raised when the deploy subcommand can't be determined.
Expand All @@ -67,10 +77,11 @@ class AppTypeError(Exception):

@dataclass
class AppType:
"""The resolved deploy subcommand and whether it needs a local Quarto."""
"""The resolved deploy subcommand and which optional deploy steps it needs."""

deploy_type: str
needs_quarto: bool
needs_requirements: bool


def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType:
Expand All @@ -82,10 +93,16 @@ def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType:
``app_mode`` (R content has no source-deploy path here and needs a
``manifest.json``). Only the ``quarto`` subcommand runs ``quarto inspect``
locally, so ``needs_quarto`` is true exactly when the resolved type is
``quarto``.
``quarto``. ``needs_requirements`` is true only for content with Python
dependencies (see :data:`REQUIREMENTS_DEPLOY_TYPES`). It is false for
manifests even when the content is Python: a manifest deploy bundles
exactly the files the manifest lists, so the dependency file it names must
already sit beside it -- one generated at deploy time could never enter the
bundle. Node.js content declares its dependencies in
package.json/package-lock.json instead, so there is nothing to generate.
"""
if manifest_present:
return AppType(deploy_type="manifest", needs_quarto=False)
return AppType(deploy_type="manifest", needs_quarto=False, needs_requirements=False)

if not app_mode:
raise AppTypeError(
Expand All @@ -101,4 +118,9 @@ def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType:
)

deploy_type = APP_MODE_TO_TYPE.get(app_mode, app_mode)
return AppType(deploy_type=deploy_type, needs_quarto=deploy_type == "quarto")
return AppType(
deploy_type=deploy_type,
needs_quarto=deploy_type == "quarto",
needs_requirements=deploy_type in REQUIREMENTS_DEPLOY_TYPES
or app_mode.startswith(("python-", "jupyter-")),
)
14 changes: 10 additions & 4 deletions src/connect_actions/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ def cmd_resolve_app_type(_args: argparse.Namespace) -> int:
"""Map the content's app_mode to a deploy subcommand and Quarto need.

Reads ``MANIFEST_PRESENT`` (whether a ``manifest.json`` was found) and
``APP_MODE`` (from ``posit connect api``), then writes ``app_type`` and
``needs_quarto`` so the action can conditionally set up Quarto and hand the
subcommand to the deploy step.
``APP_MODE`` (from ``posit connect api``), then writes ``app_type``,
``needs_quarto``, and ``needs_requirements`` so the action can
conditionally set up Quarto, skip requirements generation for non-Python
content, and hand the subcommand to the deploy step.
"""
try:
app_type = resolve_app_type(
Expand All @@ -84,10 +85,15 @@ def cmd_resolve_app_type(_args: argparse.Namespace) -> int:
print(f"Error: {err}", file=sys.stderr)
return 1

print(f"Resolved app type: {app_type.deploy_type} (needs_quarto={app_type.needs_quarto})")
print(
f"Resolved app type: {app_type.deploy_type} "
f"(needs_quarto={app_type.needs_quarto}, "
f"needs_requirements={app_type.needs_requirements})"
)
_write_output(
app_type=app_type.deploy_type,
needs_quarto="true" if app_type.needs_quarto else "false",
needs_requirements="true" if app_type.needs_requirements else "false",
)
return 0

Expand Down
38 changes: 34 additions & 4 deletions tests/test_apptype.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def test_manifest_short_circuits_lookup():
# needs a local Quarto install.
result = resolve_app_type(manifest_present=True, app_mode="quarto-static")

assert result == AppType(deploy_type="manifest", needs_quarto=False)
assert result == AppType(deploy_type="manifest", needs_quarto=False, needs_requirements=False)


@pytest.mark.parametrize(
Expand All @@ -35,21 +35,51 @@ def test_quarto_static_needs_quarto():
assert resolve_app_type(manifest_present=False, app_mode="quarto-static").needs_quarto is True


@pytest.mark.parametrize(
"app_mode",
[
"python-shiny",
"python-fastapi",
"python-flask",
"python-dash",
"python-streamlit",
"python-bokeh",
# Quarto may run Python via the jupyter engine, so it keeps the
# requirements step.
"quarto-static",
# Fall-through modes with a Python/Jupyter prefix are Python content
# even without a mapping.
"python-gradio",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this means that our mapping is incomplete, and a gradio app would fail to deploy. (There is effectively no "fall-through mode".) We should fix that and make sure that everything on https://docs.posit.co/connect/user/manifest/index.html#appmode is supported.

"jupyter-static",
],
)
def test_python_content_needs_requirements(app_mode):
assert resolve_app_type(manifest_present=False, app_mode=app_mode).needs_requirements is True


def test_nodejs_falls_through_and_skips_requirements():
# Node.js content has no Python dependency source, so the requirements step
# must be skipped instead of failing on a missing pyproject/uv.lock.
result = resolve_app_type(manifest_present=False, app_mode="nodejs")

assert result == AppType(deploy_type="nodejs", needs_quarto=False, needs_requirements=False)


def test_quarto_shiny_falls_through_unchanged():
# Connect doesn't support Python shiny-backed Quarto docs (only R), so there
# is no mapping for quarto-shiny; it passes straight through. See
# https://github.com/posit-dev/rsconnect-python/pull/755#issuecomment-4271245574
result = resolve_app_type(manifest_present=False, app_mode="quarto-shiny")

assert result == AppType(deploy_type="quarto-shiny", needs_quarto=False)
assert result == AppType(deploy_type="quarto-shiny", needs_quarto=False, needs_requirements=False)


def test_unknown_app_mode_falls_through_unchanged():
# An unrecognized mode passes straight to `posit connect deploy`, which will
# reject it if genuinely unsupported.
result = resolve_app_type(manifest_present=False, app_mode="python-gradio")

assert result == AppType(deploy_type="python-gradio", needs_quarto=False)
assert result == AppType(deploy_type="python-gradio", needs_quarto=False, needs_requirements=True)


def test_empty_app_mode_without_manifest_errors():
Expand Down Expand Up @@ -77,4 +107,4 @@ def test_r_app_mode_with_manifest_still_deploys():
# short-circuits the R check and deploys the manifest directly.
result = resolve_app_type(manifest_present=True, app_mode="shiny")

assert result == AppType(deploy_type="manifest", needs_quarto=False)
assert result == AppType(deploy_type="manifest", needs_quarto=False, needs_requirements=False)
16 changes: 16 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def test_resolve_app_type_maps_app_mode(tmp_path, monkeypatch):
written = output_file.read_text()
assert "app_type=quarto" in written
assert "needs_quarto=true" in written
assert "needs_requirements=true" in written


def test_resolve_app_type_manifest(tmp_path, monkeypatch):
Expand All @@ -118,6 +119,21 @@ def test_resolve_app_type_manifest(tmp_path, monkeypatch):
written = output_file.read_text()
assert "app_type=manifest" in written
assert "needs_quarto=false" in written
assert "needs_requirements=false" in written


def test_resolve_app_type_nodejs_skips_requirements(tmp_path, monkeypatch):
output_file = tmp_path / "github_output"
monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
monkeypatch.setenv("MANIFEST_PRESENT", "false")
monkeypatch.setenv("APP_MODE", "nodejs")

assert main(["resolve-app-type"]) == 0

written = output_file.read_text()
assert "app_type=nodejs" in written
assert "needs_quarto=false" in written
assert "needs_requirements=false" in written


def test_resolve_app_type_empty_mode_exits_nonzero(tmp_path, monkeypatch, capsys):
Expand Down
Loading