From e06676c3bb08a315c5f34d79e94586b412740b35 Mon Sep 17 00:00:00 2001 From: Jonathan Keane Date: Fri, 17 Jul 2026 14:33:08 -0500 Subject: [PATCH 1/2] include extra files for quarto --- CLAUDE.md | 6 ++--- deploy/action.yml | 1 + deploy/scripts/deploy.sh | 20 ++++++++++++++-- src/connect_actions/cli.py | 19 +++++++++++++-- src/connect_actions/config.py | 36 ++++++++++++++++++++++++++-- tests/test_cli.py | 36 ++++++++++++++++++++++++++++ tests/test_config.py | 44 ++++++++++++++++++++++++++++++++--- 7 files changed, 150 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 372c8c2..49e473d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Both actions resolve Connect server URL and content GUID through the same 3-tier 2. Specified deployment TOML file (`deployment-file` input) 3. Auto-detection from `.posit/publish/deployments/` (must find exactly one `.toml`) -This logic lives in `src/connect_actions/config.py` (`resolve_config()`), invoked by both actions via `uv run --project ${{ github.action_path }}/.. python -m connect_actions.cli resolve-config`. The pure functions parse TOML with stdlib `tomllib` and return a `Config`; the thin `cli.py` layer reads `INPUT_*` env vars and writes `GITHUB_OUTPUT`. Deploy uses the `entrypoint` field from the TOML `[configuration]` section; cleanup-previews ignores it. +This logic lives in `src/connect_actions/config.py` (`resolve_config()`), invoked by both actions via `uv run --project ${{ github.action_path }}/.. python -m connect_actions.cli resolve-config`. The pure functions parse TOML with stdlib `tomllib` and return a `Config`; the thin `cli.py` layer reads `INPUT_*` env vars and writes `GITHUB_OUTPUT`. Deploy uses the `entrypoint` field from the TOML `[configuration]` section (cleanup-previews ignores it) and its `extra_files`, derived from the `[configuration].files` array: every declared file minus the entrypoint, `requirements.txt`, and anything under `.posit/`. These are the supplementary sources a single-file entrypoint won't auto-bundle (e.g. a `.py` a Quarto document imports); the deploy step passes them to `posit connect deploy quarto` as trailing `EXTRA_FILES` positionals. `extra_files` is emitted as a newline-delimited multi-line `GITHUB_OUTPUT` value so filenames may contain spaces. ### Shared login pattern @@ -33,13 +33,13 @@ Both actions authenticate once via `scripts/login.sh`, which logs the `posit` CL ### Deploy flow (`deploy/`) 1. Install `uv` and the `posit` CLI -2. Resolve config (server, GUID, entrypoint) via `connect_actions.cli resolve-config` +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`) 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`) -8. Run `posit connect deploy` with the resolved app type, `--draft` for PRs, passing `--metadata` only when `send_metadata` is true +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` 11. Log out (teardown) diff --git a/deploy/action.yml b/deploy/action.yml index 8536551..bddef11 100644 --- a/deploy/action.yml +++ b/deploy/action.yml @@ -134,6 +134,7 @@ runs: CONTENT_GUID: ${{ steps.config.outputs.content_guid }} APP_TYPE: ${{ steps.apptype.outputs.app_type }} CONFIG_ENTRYPOINT: ${{ steps.config.outputs.entrypoint }} + EXTRA_FILES: ${{ steps.config.outputs.extra_files }} DRAFT: ${{ inputs.draft }} SEND_METADATA: ${{ steps.capabilities.outputs.send_metadata }} GITHUB_EVENT_NAME: ${{ github.event_name }} diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index 3342749..c040d7a 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -4,13 +4,15 @@ # server URL or API key is passed here. # Required env vars: CONTENT_GUID, APP_TYPE (resolved by the "Determine app type" # step: a `posit connect deploy` subcommand, or "manifest") -# Optional env vars: CONFIG_ENTRYPOINT, DRAFT, GITHUB_EVENT_NAME, RSCONNECT_ARGS +# Optional env vars: CONFIG_ENTRYPOINT, EXTRA_FILES, DRAFT, GITHUB_EVENT_NAME, +# RSCONNECT_ARGS set -euo pipefail # The app type is resolved upstream (from a manifest.json or the Connect content # record's app_mode) so the action can set up Quarto before this step runs. ENTRYPOINT_ARGS=() +EXTRA_FILE_ARGS=() if [ "$APP_TYPE" = "manifest" ]; then # A manifest.json already declares app type, entrypoint, and dependencies, so # we deploy it directly and ignore CONFIG_ENTRYPOINT. @@ -30,6 +32,20 @@ else ENTRYPOINT_ARGS=(--entrypoint "$CONFIG_ENTRYPOINT") fi fi + + # A single-file quarto entrypoint only bundles the files rsconnect can infer, + # so any supplementary source files declared in the deployment TOML (resolved + # into EXTRA_FILES by resolve-config) are passed as EXTRA_FILES positionals. + # Other app types deploy the whole directory ("."), which already covers them. + # EXTRA_FILES is newline-delimited so filenames may contain spaces. + if [ "$APP_TYPE" = "quarto" ] && [ -n "${EXTRA_FILES:-}" ]; then + while IFS= read -r extra_file; do + [ -n "$extra_file" ] && EXTRA_FILE_ARGS+=("$extra_file") + done <<< "$EXTRA_FILES" + if [ "${#EXTRA_FILE_ARGS[@]}" -gt 0 ]; then + echo "Including extra files: ${EXTRA_FILE_ARGS[*]}" + fi + fi fi # Deploy as a draft when requested. The DRAFT env var is set from the action's @@ -84,7 +100,7 @@ if [ "${SEND_METADATA:-true}" = "true" ]; then fi # shellcheck disable=SC2086 -posit connect deploy "$APP_TYPE" "${DRAFT_ARGS[@]}" --app-id "$CONTENT_GUID" "${ENTRYPOINT_ARGS[@]}" "${METADATA_ARGS[@]}" ${RSCONNECT_ARGS:-} "$DEPLOY_TARGET" 2>&1 | tee deploy.log +posit connect deploy "$APP_TYPE" "${DRAFT_ARGS[@]}" --app-id "$CONTENT_GUID" "${ENTRYPOINT_ARGS[@]}" "${METADATA_ARGS[@]}" ${RSCONNECT_ARGS:-} "$DEPLOY_TARGET" "${EXTRA_FILE_ARGS[@]}" 2>&1 | tee deploy.log # Extract URL from logs, stripping ANSI color codes CONTENT_URL=$(grep "$URL_PATTERN" deploy.log | sed "s/.*$URL_PATTERN //" | sed 's/\x1b\[[0-9;]*m//g') diff --git a/src/connect_actions/cli.py b/src/connect_actions/cli.py index 92e6bcf..7d33a50 100644 --- a/src/connect_actions/cli.py +++ b/src/connect_actions/cli.py @@ -16,9 +16,23 @@ from .versions import format_min_version, supports +def _format_output(key: str, value: str) -> str: + """Format a single ``GITHUB_OUTPUT`` entry. + + Values containing newlines use the heredoc form so multi-line outputs (e.g. + a newline-delimited file list, whose entries may contain spaces) survive + intact; everything else uses the plain ``key=value`` form. + """ + if "\n" in value: + # A delimiter that cannot appear in the value (GHA rejects it otherwise). + delimiter = "__GHA_EOF__" + return f"{key}<<{delimiter}\n{value}\n{delimiter}" + return f"{key}={value}" + + def _write_output(**values: str) -> None: - """Append ``key=value`` lines to ``GITHUB_OUTPUT`` (or stdout when unset).""" - lines = [f"{key}={value}" for key, value in values.items()] + """Append output entries to ``GITHUB_OUTPUT`` (or stdout when unset).""" + lines = [_format_output(key, value) for key, value in values.items()] github_output = os.environ.get("GITHUB_OUTPUT") if github_output: with open(github_output, "a") as f: @@ -44,6 +58,7 @@ def cmd_resolve_config(_args: argparse.Namespace) -> int: connect_server=config.connect_server, content_guid=config.content_guid, entrypoint=config.entrypoint, + extra_files="\n".join(config.extra_files), ) return 0 diff --git a/src/connect_actions/config.py b/src/connect_actions/config.py index 38f9f37..b452342 100644 --- a/src/connect_actions/config.py +++ b/src/connect_actions/config.py @@ -16,7 +16,7 @@ import tomllib from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path DEPLOYMENTS_DIR = ".posit/publish/deployments" @@ -29,6 +29,7 @@ class Config: connect_server: str content_guid: str entrypoint: str = "" + extra_files: list[str] = field(default_factory=list) class ConfigError(Exception): @@ -69,6 +70,35 @@ def find_deployment_file(base_dir: Path) -> Path: return toml_files[0] +def _resolve_extra_files(files: list[str], entrypoint: str) -> list[str]: + """Pick supplementary source files to pass to ``posit connect deploy``. + + Publisher records every bundled file in the ``[configuration].files`` array + with a leading ``/`` (relative to the project root). Quarto's deploy takes + the entrypoint document as its positional and only auto-detects the files it + can infer, so any other declared source file (e.g. a ``.py`` the document + imports) must be passed explicitly as an ``EXTRA_FILES`` positional. + + We keep those declared files but drop the ones that shouldn't (or needn't) + be passed as extras: + + * the entrypoint itself -- already the positional argument; + * ``requirements.txt`` -- rsconnect includes it automatically, and the + action regenerates it; + * anything under ``.posit/`` -- deployment metadata, not runtime files (and + the recorded names there aren't always faithful to what's on disk). + """ + extras: list[str] = [] + for entry in files: + rel = entry.lstrip("/") + if not rel or rel == entrypoint or rel == "requirements.txt": + continue + if rel == ".posit" or rel.startswith(".posit/"): + continue + extras.append(rel) + return extras + + def parse_deployment_file(path: Path) -> Config: """Parse a Posit deployment TOML file into a :class:`Config`. @@ -78,10 +108,12 @@ def parse_deployment_file(path: Path) -> Config: data = tomllib.load(f) configuration = data.get("configuration", {}) + entrypoint = configuration.get("entrypoint", "") return Config( connect_server=data.get("server_url", ""), content_guid=data.get("id", ""), - entrypoint=configuration.get("entrypoint", ""), + entrypoint=entrypoint, + extra_files=_resolve_extra_files(configuration.get("files", []), entrypoint), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 921dddb..6a1d5cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -46,6 +46,42 @@ def test_resolve_config_reads_deployment_file(tmp_path, monkeypatch): assert "entrypoint=app:app" in written +def test_resolve_config_writes_extra_files_as_heredoc(tmp_path, monkeypatch): + toml_path = tmp_path / DEPLOYMENTS / "app.toml" + toml_path.parent.mkdir(parents=True) + toml_path.write_text( + 'server_url = "https://connect.example.com"\n' + 'id = "abc-123"\n' + "[configuration]\n" + 'entrypoint = "report.qmd"\n' + 'files = ["/report.qmd", "/helper.py", "/data/input.csv"]\n' + ) + output_file = tmp_path / "github_output" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.delenv("INPUT_CONNECT_SERVER", raising=False) + monkeypatch.delenv("INPUT_CONTENT_GUID", raising=False) + monkeypatch.delenv("INPUT_DEPLOYMENT_FILE", raising=False) + + assert main(["resolve-config"]) == 0 + + written = output_file.read_text() + # Multi-line outputs use the heredoc form so each file lands on its own line. + assert "extra_files<<__GHA_EOF__\nhelper.py\ndata/input.csv\n__GHA_EOF__" in written + + +def test_resolve_config_empty_extra_files_uses_plain_form(tmp_path, monkeypatch): + output_file = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setenv("INPUT_CONNECT_SERVER", "https://connect.example.com") + monkeypatch.setenv("INPUT_CONTENT_GUID", "guid-123") + monkeypatch.setenv("INPUT_DEPLOYMENT_FILE", "") + + assert main(["resolve-config"]) == 0 + + assert "extra_files=\n" in output_file.read_text() + + def test_resolve_config_error_exits_nonzero(tmp_path, monkeypatch, capsys): monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "github_output")) diff --git a/tests/test_config.py b/tests/test_config.py index 769fa80..45238a0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,7 +17,7 @@ DEPLOYMENTS = ".posit/publish/deployments" -def write_toml(path: Path, *, server="", guid="", entrypoint=None) -> Path: +def write_toml(path: Path, *, server="", guid="", entrypoint=None, files=None) -> Path: """Write a minimal Posit deployment TOML at ``path``.""" path.parent.mkdir(parents=True, exist_ok=True) lines = [] @@ -25,9 +25,13 @@ def write_toml(path: Path, *, server="", guid="", entrypoint=None) -> Path: lines.append(f'server_url = "{server}"') if guid: lines.append(f'id = "{guid}"') - if entrypoint is not None: + if entrypoint is not None or files is not None: lines.append("[configuration]") + if entrypoint is not None: lines.append(f'entrypoint = "{entrypoint}"') + if files is not None: + rendered = ", ".join(f'"{f}"' for f in files) + lines.append(f"files = [{rendered}]") path.write_text("\n".join(lines) + "\n") return path @@ -141,9 +145,43 @@ def test_missing_keys_resolve_to_empty(tmp_path): toml_path.write_text("unrelated = true\n") assert parse_deployment_file(toml_path) == Config( - connect_server="", content_guid="", entrypoint="" + connect_server="", content_guid="", entrypoint="", extra_files=[] + ) + + +def test_extra_files_excludes_entrypoint_requirements_and_posit(tmp_path): + # The declared files array carries the entrypoint, requirements.txt, .posit + # metadata, and supplementary sources. Only the supplementary sources should + # come back as extra files (entrypoint is the positional; requirements.txt + # and .posit/ are handled elsewhere or not needed at runtime). + config = parse_deployment_file( + write_toml( + tmp_path / "app.toml", + server="s", + guid="g", + entrypoint="report.qmd", + files=[ + "/report.qmd", + "/requirements.txt", + "/.posit/publish/Config-ABCD.toml", + "/.posit/publish/deployments/deployment-WXYZ.toml", + "/helper.py", + "/data/input.csv", + ], + ) ) + assert config.entrypoint == "report.qmd" + assert config.extra_files == ["helper.py", "data/input.csv"] + + +def test_extra_files_empty_without_files_array(tmp_path): + config = parse_deployment_file( + write_toml(tmp_path / "app.toml", server="s", guid="g", entrypoint="report.qmd") + ) + + assert config.extra_files == [] + def test_log_callback_reports_progress(tmp_path): write_toml(tmp_path / DEPLOYMENTS / "app.toml", server="s", guid="g") From 81e2d2d9b13cca2e603fdae2a4a8ec474026a504 Mon Sep 17 00:00:00 2001 From: Jonathan Keane Date: Fri, 17 Jul 2026 15:44:02 -0500 Subject: [PATCH 2/2] Also e2e tests --- .github/workflows/test.yml | 24 ++++++++++++++++++++++-- tests/e2e/README.md | 12 ++++++++++++ tests/e2e/quarto-doc/data/message.txt | 1 + tests/e2e/quarto-doc/report.qmd | 12 ++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/quarto-doc/data/message.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74ceaef..2ddcadd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,13 +50,20 @@ jobs: # server in e2e-deploy, which pins the manifest's version after download). # `quarto inspect` reads the doc's metadata without executing it, so no # Python/Jupyter environment is needed. + # Run from the fixture directory so the EXTRA_FILES positional resolves the + # way rsconnect expects (relative to the entrypoint's directory). + # report.qmd reads data/message.txt, a file a single-file Quarto entrypoint + # won't auto-bundle, so it's passed explicitly here to get it into the + # manifest -- otherwise this leg's render would fail on the missing file. - name: Generate manifest for Quarto fixture + working-directory: tests/e2e/quarto-doc run: | set -euo pipefail uvx --from "git+https://github.com/posit-dev/rsconnect-python" \ rsconnect write-manifest quarto \ --overwrite \ - tests/e2e/quarto-doc/report.qmd + report.qmd \ + data/message.txt - name: Upload Quarto fixture manifest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -542,12 +549,21 @@ jobs: cp -r tests/e2e/quarto-doc "$APP" rm -f "$APP/manifest.json" mkdir -p "$APP/.posit/publish/deployments" + # The files array is what Posit Publisher records for the content; the + # action derives extra_files from it (everything but the entrypoint, + # requirements.txt, and .posit/). report.qmd reads data/message.txt, + # which a single-file Quarto entrypoint won't auto-bundle, so listing it + # here makes the action pass it as an EXTRA_FILES positional to + # `posit connect deploy quarto`. This is the leg that exercises the + # extra_files action code: drop the file from the array and this + # manifest-free deploy's render fails on the missing file. cat > "$APP/.posit/publish/deployments/quarto.toml" <= 2 bundles. COUNT=$(posit connect api "v1/content/$GUID/bundles" -q 'length') [ "$COUNT" -ge 2 ] || { echo "ERROR: expected a second bundle on the Quarto record"; exit 1; } - echo "Quarto app_mode deploy verified." + # This deploy's report.qmd reads data/message.txt, which reaches Connect + # only via the TOML's [configuration].files -> extra_files path. If that + # path dropped the file the render (and so this deploy) would have + # failed, so reaching here already proves the extra file was included. + echo "Quarto app_mode deploy verified (extra file bundled and rendered)." # --- Draft deploy (draft: true) ------------------------------------------- # Draft previews require Connect >= 2025.07.0 (older servers fail fast), so diff --git a/tests/e2e/README.md b/tests/e2e/README.md index e099d91..e454794 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -40,6 +40,18 @@ A sanity check that the `deploy` action can actually deploy to a real Connect: `deploy.sh` maps `quarto-static` to the `quarto` subcommand and passes the resolved entrypoint as the positional document argument (Quarto has no `--entrypoint` flag). + + This second leg also exercises the `extra_files` path. `report.qmd` reads a + supplementary file ([`data/message.txt`](quarto-doc/data/message.txt)) that a + single-file Quarto entrypoint won't auto-bundle, so the render — and therefore + the deploy — only succeeds if the file is carried along. On this leg the + deployment TOML declares it in `[configuration].files`, which the action + derives `extra_files` from and passes to `posit connect deploy quarto` as an + `EXTRA_FILES` positional; drop it from the array and this deploy fails. That + makes this the leg covering the `extra_files` action code. (The manifest + bootstrap leg renders the same document, so it carries the file too — but + there it just rides in the manifest, added via `write-manifest`'s extra-file + argument, not through the `extra_files` action code.) 5. The job verifies each deploy set a non-empty `content-url`, that the URL is a draft URL only for the draft deploy, and that bundles were uploaded. diff --git a/tests/e2e/quarto-doc/data/message.txt b/tests/e2e/quarto-doc/data/message.txt new file mode 100644 index 0000000..430319f --- /dev/null +++ b/tests/e2e/quarto-doc/data/message.txt @@ -0,0 +1 @@ +Hello from an extra bundled file. diff --git a/tests/e2e/quarto-doc/report.qmd b/tests/e2e/quarto-doc/report.qmd index 379916c..d35140c 100644 --- a/tests/e2e/quarto-doc/report.qmd +++ b/tests/e2e/quarto-doc/report.qmd @@ -20,3 +20,15 @@ values = [n**2 for n in range(1, 6)] print(f"Squares: {values}") print(f"Sum: {sum(values)}") ``` + +## Supplementary file + +Reads `data/message.txt`, a supplementary file that a single-file Quarto +entrypoint won't auto-bundle -- it must be carried along explicitly (as an extra +file passed to the deploy, or listed in the manifest). + +```{python} +from pathlib import Path + +print(f"Included file says: {Path('data/message.txt').read_text().strip()}") +```