From 7c5238cce175cc3c01884bfc5bd0d19676aa815a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 21 Jul 2026 08:27:35 -0400 Subject: [PATCH 1/2] Fail with R-tailored error when R content lacks a manifest.json Deploying R content without a manifest.json fell through to the Python requirements path and failed with a confusing uv.lock/pyproject.toml error. Detect R app_modes (shiny, rmd-shiny, rmd-static, api) from the Connect content record in resolve-app-type -- which runs before generate-requirements.sh -- and fail fast with a message pointing to rsconnect::writeManifest(). A present manifest.json still short-circuits, so the supported R path works. Fixes #69 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src/connect_actions/apptype.py | 33 ++++++++++++++++++++++++++++++--- tests/test_apptype.py | 23 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49e473d..e24c657 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ 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`) +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`) can't be built from source here, so without a `manifest.json` this step fails fast with an R-tailored error (pointing to `rsconnect::writeManifest()`) rather than falling through to the Python requirements path 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`) diff --git a/src/connect_actions/apptype.py b/src/connect_actions/apptype.py index c0eaa0d..7815782 100644 --- a/src/connect_actions/apptype.py +++ b/src/connect_actions/apptype.py @@ -39,6 +39,23 @@ "quarto-static": "quarto", } +# Connect ``app_mode`` values for R content. The ``posit`` CLI (via +# rsconnect-python) can only build a bundle for Python and Quarto content from +# source; R content has no source-deploy path here and must be deployed from a +# pre-built ``manifest.json`` (generated in R with ``rsconnect::writeManifest()``). +# We single these out so a missing manifest produces an R-tailored error instead +# of falling through to the Python requirements-generation path. ``quarto-shiny`` +# is R-backed too but is handled separately (it falls through unchanged); see the +# note on ``APP_MODE_TO_TYPE`` above. +R_APP_MODES: frozenset[str] = frozenset( + { + "shiny", # R Shiny + "rmd-shiny", # interactive R Markdown + "rmd-static", # rendered R Markdown + "api", # R Plumber API + } +) + class AppTypeError(Exception): """Raised when the deploy subcommand can't be determined. @@ -61,9 +78,11 @@ def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType: With a ``manifest.json`` present the type is ``manifest`` and Quarto is never needed. Otherwise ``app_mode`` (from the Connect content record) is mapped to - a subcommand; an empty ``app_mode`` raises :class:`AppTypeError`. Only the - ``quarto`` subcommand runs ``quarto inspect`` locally, so ``needs_quarto`` is - true exactly when the resolved type is ``quarto``. + a subcommand; an empty ``app_mode`` raises :class:`AppTypeError`, as does an R + ``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``. """ if manifest_present: return AppType(deploy_type="manifest", needs_quarto=False) @@ -74,5 +93,13 @@ def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType: "Provide a manifest.json or ensure the content GUID is correct." ) + if app_mode in R_APP_MODES: + raise AppTypeError( + f"This content is R-based (app_mode '{app_mode}'), which this action " + "cannot build and deploy from source. R content must be deployed from " + "a manifest.json. Generate one in R with rsconnect::writeManifest() and " + "commit it to your repository, then re-run the deploy." + ) + deploy_type = APP_MODE_TO_TYPE.get(app_mode, app_mode) return AppType(deploy_type=deploy_type, needs_quarto=deploy_type == "quarto") diff --git a/tests/test_apptype.py b/tests/test_apptype.py index 662bfb7..3c7003e 100644 --- a/tests/test_apptype.py +++ b/tests/test_apptype.py @@ -55,3 +55,26 @@ def test_unknown_app_mode_falls_through_unchanged(): def test_empty_app_mode_without_manifest_errors(): with pytest.raises(AppTypeError, match="Could not determine app_mode"): resolve_app_type(manifest_present=False, app_mode="") + + +@pytest.mark.parametrize("app_mode", ["shiny", "rmd-shiny", "rmd-static", "api"]) +def test_r_app_modes_without_manifest_error_with_r_guidance(app_mode): + # R content can't be built from source here; the error must name R and point + # to a manifest.json rather than mentioning uv.lock/pyproject.toml/Python. + with pytest.raises(AppTypeError) as excinfo: + resolve_app_type(manifest_present=False, app_mode=app_mode) + + message = str(excinfo.value) + assert "R" in message + assert "manifest.json" in message + assert "writeManifest" in message + assert "uv" not in message.lower() + assert "pyproject" not in message.lower() + + +def test_r_app_mode_with_manifest_still_deploys(): + # A manifest.json is exactly the supported path for R content, so its presence + # 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) From 02ca8371516d8f6830dfc9d8f21c68ffb20950b1 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 21 Jul 2026 08:31:54 -0400 Subject: [PATCH 2/2] Better words Co-authored-by: Neal Richardson --- CLAUDE.md | 2 +- src/connect_actions/apptype.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e24c657..60f3f73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ 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`) can't be built from source here, so without a `manifest.json` this step fails fast with an R-tailored error (pointing to `rsconnect::writeManifest()`) rather than falling through to the Python requirements path +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. 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`) diff --git a/src/connect_actions/apptype.py b/src/connect_actions/apptype.py index 7815782..6a10689 100644 --- a/src/connect_actions/apptype.py +++ b/src/connect_actions/apptype.py @@ -95,8 +95,7 @@ def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType: if app_mode in R_APP_MODES: raise AppTypeError( - f"This content is R-based (app_mode '{app_mode}'), which this action " - "cannot build and deploy from source. R content must be deployed from " + f"R content (app_mode '{app_mode}') requires " "a manifest.json. Generate one in R with rsconnect::writeManifest() and " "commit it to your repository, then re-run the deploy." )