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 CLAUDE.md
Original file line number Diff line number Diff line change
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), 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.
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` 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
7. Generate `requirements.txt` if missing (`generate-requirements.sh`, from `uv.lock` or `pyproject.toml`). The script branches only on the files present in the working directory; with no dependency source at all it prints a note and exits 0. Whether the content actually needs a `requirements.txt` is `posit connect deploy`'s call — it errors clearly, naming the file, when one is required and missing
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ 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:
Otherwise, Connect installs your app's Python 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.
2. **`uv.lock`** -- exported with `uv export --no-hashes --no-emit-project --frozen`, pinning the exact versions from your lockfile (the lockfile is used as-is; it is never re-resolved at deploy time).
3. **`pyproject.toml`** -- resolved at deploy time with `uv pip compile`.

If none of these exist, the action generates nothing and moves on. That is the expected case for content with no Python dependencies---Node.js apps, whose dependencies come from `package.json`/`package-lock.json`, or a Quarto document using the knitr engine. If the content does need a `requirements.txt`, the deploy step fails with a message naming the missing file.

For reproducible deploys, we recommend checking a lockfile into your repo alongside `pyproject.toml`: either a `uv.lock` (run `uv lock`) or a pinned `requirements.txt` (run `uv pip compile pyproject.toml -o requirements.txt`). Without one, the action re-resolves your dependencies from `pyproject.toml` on every deploy, so an upstream release can change what gets deployed. To keep a checked-in lockfile fresh, add a scheduled job or a tool like [Dependabot](https://docs.github.com/en/code-security/dependabot) to open update PRs.

#### Example
Expand Down
7 changes: 3 additions & 4 deletions deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,10 @@ runs:
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.
# generated here from uv.lock or pyproject.toml when absent. Content with no
# Python dependency source (e.g. Node.js apps) has nothing to generate and
# passes through untouched.
- 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
13 changes: 7 additions & 6 deletions deploy/scripts/generate-requirements.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/bin/bash
# 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.
# Generate requirements.txt from a uv.lock or pyproject.toml when it is absent.
#
# Which branch runs depends only on the files in the working directory. A
# missing dependency source is not an error: `posit connect deploy` is what
# decides whether the content needs a requirements.txt (a jupyter-engine Quarto
# doc does; a knitr-engine one and a Node.js app do not) and reports it if so.

set -euo pipefail

Expand Down Expand Up @@ -31,6 +33,5 @@ elif [ -f "pyproject.toml" ]; then
echo "pyproject.toml found, generating requirements.txt from pyproject.toml..."
uv pip compile pyproject.toml -o requirements.txt
else
echo "No uv.lock or pyproject.toml file found. Please run 'uv sync' to generate uv.lock or create a pyproject.toml before deploying."
exit 1
echo "No uv.lock or pyproject.toml file found; nothing to generate."
fi
4 changes: 2 additions & 2 deletions skills/setup-connect-deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ Connect needs to know your app's dependencies. Check the app directory:
`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.
`package.json`/`package-lock.json`, and with no Python dependency source
present the action has nothing to generate.

Don't generate these files yourself unless the user asks — just report what you
found and recommend.
Expand Down
34 changes: 6 additions & 28 deletions src/connect_actions/apptype.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@
# 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.
# of falling through to a Python-flavored one from the deploy step.
# ``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
Expand All @@ -56,16 +56,6 @@
}
)

# 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 @@ -81,7 +71,6 @@ class AppType:

deploy_type: str
needs_quarto: bool
needs_requirements: bool


def resolve_app_type(*, manifest_present: bool, app_mode: str) -> AppType:
Expand All @@ -93,16 +82,10 @@ 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``. ``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.
``quarto``.
"""
if manifest_present:
return AppType(deploy_type="manifest", needs_quarto=False, needs_requirements=False)
return AppType(deploy_type="manifest", needs_quarto=False)

if not app_mode:
raise AppTypeError(
Expand All @@ -118,9 +101,4 @@ 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",
needs_requirements=deploy_type in REQUIREMENTS_DEPLOY_TYPES
or app_mode.startswith(("python-", "jupyter-")),
)
return AppType(deploy_type=deploy_type, needs_quarto=deploy_type == "quarto")
14 changes: 4 additions & 10 deletions src/connect_actions/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,9 @@ 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``,
``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.
``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.
"""
try:
app_type = resolve_app_type(
Expand All @@ -85,15 +84,10 @@ 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} "
f"(needs_quarto={app_type.needs_quarto}, "
f"needs_requirements={app_type.needs_requirements})"
)
print(f"Resolved app type: {app_type.deploy_type} (needs_quarto={app_type.needs_quarto})")
_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
36 changes: 6 additions & 30 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, needs_requirements=False)
assert result == AppType(deploy_type="manifest", needs_quarto=False)


@pytest.mark.parametrize(
Expand All @@ -35,34 +35,10 @@ 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",
"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.
def test_nodejs_falls_through_unchanged():
result = resolve_app_type(manifest_present=False, app_mode="nodejs")

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


def test_quarto_shiny_falls_through_unchanged():
Expand All @@ -71,15 +47,15 @@ def test_quarto_shiny_falls_through_unchanged():
# 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, needs_requirements=False)
assert result == AppType(deploy_type="quarto-shiny", needs_quarto=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, needs_requirements=True)
assert result == AppType(deploy_type="python-gradio", needs_quarto=False)


def test_empty_app_mode_without_manifest_errors():
Expand Down Expand Up @@ -107,4 +83,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, needs_requirements=False)
assert result == AppType(deploy_type="manifest", needs_quarto=False)
16 changes: 0 additions & 16 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ 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 @@ -119,21 +118,6 @@ 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
18 changes: 11 additions & 7 deletions tests/test_generate_requirements.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Tests for deploy/scripts/generate-requirements.sh.

These drive the shell script directly in a temp directory and assert which
dependency source it picks. The script orders sources manifest.json ->
requirements.txt -> uv.lock -> pyproject.toml, so the tests pin down that
ordering (and the --frozen export behavior) rather than just "a deploy
succeeds," which can't tell the branches apart.
dependency source it picks. The script decides purely from the files present,
ordering sources manifest.json -> requirements.txt -> uv.lock -> pyproject.toml,
so the tests pin down that ordering (and the --frozen export behavior) rather
than just "a deploy succeeds," which can't tell the branches apart.

The uv.lock and pyproject.toml branches shell out to ``uv``, which resolves
from PyPI; these tests need network access (the same as ``uv run pytest``).
Expand Down Expand Up @@ -100,9 +100,13 @@ def test_pyproject_only_compiles(tmp_path):
assert "iniconfig==2.0.0" in (tmp_path / "requirements.txt").read_text()


def test_no_sources_errors(tmp_path):
# Nothing to deploy from -> non-zero exit and no requirements.txt.
def test_no_sources_is_not_an_error(tmp_path):
# No dependency source of any kind: nothing to generate, and not an error.
# Content without Python dependencies (Node.js apps, knitr-engine Quarto
# docs) deploys fine, and `posit connect deploy` reports the missing
# requirements.txt for content that does need one.
result = run(tmp_path)

assert result.returncode != 0
assert result.returncode == 0, result.stderr
assert "nothing to generate" in result.stdout
assert not (tmp_path / "requirements.txt").exists()
Loading