diff --git a/CLAUDE.md b/CLAUDE.md index a552272..622c9e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/README.md b/README.md index 062ff71..e706c24 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/action.yml b/deploy/action.yml index 9d0dd5a..89efcfe 100644 --- a/deploy/action.yml +++ b/deploy/action.yml @@ -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 diff --git a/deploy/scripts/generate-requirements.sh b/deploy/scripts/generate-requirements.sh index 7c33cd5..399df90 100755 --- a/deploy/scripts/generate-requirements.sh +++ b/deploy/scripts/generate-requirements.sh @@ -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 @@ -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 diff --git a/skills/setup-connect-deploy/SKILL.md b/skills/setup-connect-deploy/SKILL.md index af2c394..c73d248 100644 --- a/skills/setup-connect-deploy/SKILL.md +++ b/skills/setup-connect-deploy/SKILL.md @@ -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. diff --git a/src/connect_actions/apptype.py b/src/connect_actions/apptype.py index c98e33e..24e37b1 100644 --- a/src/connect_actions/apptype.py +++ b/src/connect_actions/apptype.py @@ -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 @@ -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. @@ -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: @@ -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( @@ -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") diff --git a/src/connect_actions/cli.py b/src/connect_actions/cli.py index 4f7a089..7d33a50 100644 --- a/src/connect_actions/cli.py +++ b/src/connect_actions/cli.py @@ -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( @@ -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 diff --git a/tests/test_apptype.py b/tests/test_apptype.py index de46d42..e88f5ce 100644 --- a/tests/test_apptype.py +++ b/tests/test_apptype.py @@ -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( @@ -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(): @@ -71,7 +47,7 @@ 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(): @@ -79,7 +55,7 @@ def test_unknown_app_mode_falls_through_unchanged(): # 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(): @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c50eb6..6a1d5cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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): @@ -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): diff --git a/tests/test_generate_requirements.py b/tests/test_generate_requirements.py index b4ea613..3e7b6fd 100644 --- a/tests/test_generate_requirements.py +++ b/tests/test_generate_requirements.py @@ -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``). @@ -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()