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
24 changes: 22 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" <<EOF
type = "quarto-static"
server_url = "$CONNECT_SERVER"
id = "$QUARTO_GUID"
[configuration]
entrypoint = "report.qmd"
files = ["/report.qmd", "/requirements.txt", "/data/message.txt"]
EOF

- name: Deploy (Quarto, app_mode lookup)
Expand Down Expand Up @@ -577,7 +593,11 @@ jobs:
# Second deploy to the Quarto record, so it should now have >= 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
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions deploy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
20 changes: 18 additions & 2 deletions deploy/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

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.

Only for quarto?

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.

This is just working around a(nother) quirk of how rsconnect deploy quarto is different from the other modes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Basically, yeah, the other modes deploy everything under . already

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
Expand Down Expand Up @@ -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')
Expand Down
19 changes: 17 additions & 2 deletions src/connect_actions/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
36 changes: 34 additions & 2 deletions src/connect_actions/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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):
Expand Down Expand Up @@ -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`.

Expand All @@ -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),
)


Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions tests/e2e/quarto-doc/data/message.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello from an extra bundled file.
12 changes: 12 additions & 0 deletions tests/e2e/quarto-doc/report.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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()}")
```
36 changes: 36 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

monkeypatch monkeypatch monkeypatch and then this, I have literally no idea what this is testing.

Could/should we add an "extra file" to the quarto e2e test that was added in the last PR to confirm? Or would that be too hard to confirm that the deployed thing was right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can try!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, I've added an e2e test for this too. I'm going to leave the monkey monkey monkey for now, but we can clean it up if it gets in the way



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"))
Expand Down
44 changes: 41 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@
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 = []
if server:
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

Expand Down Expand Up @@ -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")
Expand Down
Loading