Skip to content

Posit Publisher .posit/publish: TOML interop, redeploy, and config-driven bundling - #830

Open
mconflitti-pbc wants to merge 18 commits into
mainfrom
feat/publisher-toml-support
Open

Posit Publisher .posit/publish: TOML interop, redeploy, and config-driven bundling#830
mconflitti-pbc wants to merge 18 commits into
mainfrom
feat/publisher-toml-support

Conversation

@mconflitti-pbc

@mconflitti-pbc mconflitti-pbc commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds interoperability with Posit Publisher's .posit/publish project files, so content can be published with either rsconnect-python or the Publisher VS Code extension. Addresses #829.

rsconnect-python now reads and writes Publisher's TOML configuration (.posit/publish/<name>.toml) and deployment record (.posit/publish/deployments/<name>.toml) alongside the existing legacy rsconnect-python/*.json store, and gains a redeploy command that redeploys straight from a .posit project.

Bundling becomes config-driven only when a configuration curates a subset: if a .posit config's files list names real content patterns, exactly those files are bundled (Publisher's .gitignore-syntax include/exclude semantics — a matching pattern includes, ! excludes). Otherwise — no config, or a config declaring files = ["*"] — file selection is unchanged from today: the whole-tree walk with only the built-in ignore list. A config's integration_requests are propagated into the generated manifest.json exactly as Publisher emits them, so integrations authored in Publisher are honored on deploy.

Usage examples

1. Deploy as usual — now also writes .posit

$ rsconnect deploy shiny ./my-app -n prod
# ... deploy runs as before, and now also creates:
#   my-app/.posit/publish/my-app-4F2A.toml                     # configuration (type, entrypoint, files, python)
#   my-app/.posit/publish/deployments/deployment-9C1B.toml     # record: server_url, content id/GUID, bundle_id, dashboard/direct/logs URLs

2. Zero-arg redeploy from an existing .posit project

$ cd my-app
$ rsconnect redeploy
# reads .posit, recovers the target server + content GUID from the record,
# and redeploys in place — no framework, entrypoint, server, or app-id needed

3. Redeploy a project by path

$ rsconnect redeploy ./my-app

4. Interop: redeploy a project that was published from the Publisher VS Code extension

$ git clone git@github.com:me/my-app.git && cd my-app   # repo already contains .posit/publish/ authored by Publisher
$ rsconnect login https://connect.example.com           # one-time: authenticate the CLI to that server (OAuth)
$ rsconnect redeploy                                     # matches the record's server_url to that saved login and redeploys, reusing the GUID

5. Credentials are matched by the record's server URL

# If you've already `rsconnect add`/`login`-ed the server the record points at,
# redeploy finds it automatically — no --server / --api-key required:
$ rsconnect redeploy
# Ambiguous (two saved servers for the same URL)? It asks you to choose:
#   Error: Multiple saved servers match https://connect.example.com (prod-a, prod-b); pick one with --name.
$ rsconnect redeploy --name prod-a

6. Disambiguate when a project has multiple configs or servers

$ rsconnect redeploy --config-name my-app                # multiple .posit configs in the project
$ rsconnect redeploy --server https://connect.example.com # config deployed to more than one server

7. First-ever deploy from a Publisher config (config exists, no record yet)

$ rsconnect redeploy --server prod        # or --name prod; a destination is required the first time
# On success a deployment record is written, so subsequent `rsconnect redeploy` needs no args.

8. write-manifest now also emits a .posit config

$ rsconnect write-manifest shiny ./my-app
# writes my-app/manifest.json AND my-app/.posit/publish/my-app-7Q3D.toml

9. Redeploy pre-.posit content (manifest + legacy JSON fallback)

$ rsconnect redeploy ./legacy-app
# legacy-app has manifest.json + rsconnect-python/*.json but no .posit:
# deploys the manifest bundle to the recorded server/GUID, then writes .posit for next time

10. Config-driven bundling — only the config's files are bundled

# .posit/publish/my-app.toml declares:  files = ["/app.py", "/requirements.txt", "data/"]
$ rsconnect redeploy ./my-app
# bundles exactly app.py, requirements.txt, and everything under data/ —
# nothing else in the tree. Without a curated files list, bundling is unchanged:
# the whole tree except the built-in ignore list.

A config rsconnect-python writes itself records files = ["*"], not a snapshot of
the files that happened to deploy — so a project that never had .posit keeps
bundling identically on every subsequent deploy, and newly added modules or freshly
rendered output are still picked up.

What's included

  • New rsconnect/publisher/ package porting Publisher's format (posit-dev/publisher):
    • schema.py — schema URLs, the app_mode ↔ content-type map (from bundler/appMode.ts), .posit path helpers.
    • serialize.pytomli_w writer matching Publisher (quoted $schema first, multiline arrays, tables last); reader via stdlib tomllib / toml backport.
    • config.py / record.py — dataclasses with read‑merge‑write that preserves fields rsconnect doesn't own (incl. first-class connect_cloud, and an existing [r] table); recovers files/requirements/entrypoint from the built bundle manifest, resolving a bare Python module reference (e.g. app for app.py) back to the literal file path Publisher's schema expects, so long as the manifest confirms that file exists.
    • store.py — dual-write on deploy + resolve_publisher_deploy_target for reads; Publisher-compatible random file naming (<title>-<CODE> / deployment-<CODE>, 4-char base‑32) with find-or-reuse so redeploys update in place, disambiguated by content GUID when more than one deployment record shares a server; normalizes server_url the same way Publisher's own writers do (lowercase scheme+host, no trailing slash, no /__api__ suffix, no default port, no duplicate slashes), so a project deployed first with rsconnect-python doesn't fail a later Publisher redeploy on a byte-for-byte URL mismatch.
  • Dual-write on Connect/SPCS deploys via RSConnectExecutor.save_deployed_info (best-effort; never fails the deploy).
  • rsconnect redeploy [PATH] with the behaviors shown above, dispatching to every content type rsconnect-python can build a bundle for: Shiny, FastAPI, Flask/API, Streamlit, Dash, Bokeh, Gradio, Panel, HTML, Node.js, Jupyter notebooks (incl. Voila), and Quarto.
  • write-manifest now also emits a .posit config.
  • Config-driven file selection — new rsconnect/publisher/files.py (a pathspec/gitwildmatch engine reproducing Publisher's bundler/collect.ts: select_config_files allowlist + STANDARD_EXCLUSIONS). Wired through bundle.create_file_list via a restrict_to_files context set by the executor, so all deploy commands honor it. store.resolve_bundle_files returns the config's selection (force-including the entrypoint), or None — impose no restriction, leaving today's whole-tree walk untouched — when no config applies or when the config declares no real restriction (files absent, empty, or ["*"], discounting the .posit paths Publisher adds so they ship). Configs rsconnect mints record files = ["*"]; the concrete deployed set lives on the deployment record, where it can't be mistaken for curation.
  • integration_requestsmanifest.jsonbundle.overlay_manifest merges config-authored manifest fields rsconnect can't derive (currently integration_requests) in Manifest.__init__; store.config_manifest_overlay/resolve_manifest_overlay map them to Publisher's manifest shape.
  • Shared _plan_deploy_bundle helper extracted from deploy pyproject (reused by redeploy, including its widened content-type coverage above).
  • Adds tomli-w and pathspec dependencies.

Scope notes

  • R content types are out of scope, and not a phased rollout — rsconnect-python has no R bundle builder at all (R deploys go through the R rsconnect package), so .posit interop can't originate R content either, and redeploy never dispatches to r-shiny/r-plumber/rmd/rmd-shiny. The one R-related change here: rewriting a config now preserves an existing [r] table (matching how python/quarto are preserved), so rsconnect-python never destroys Publisher-authored R metadata it doesn't understand.
  • Connect Cloud (connect.posit.cloud) .posit files are read/preserved for interop, but deploying to Connect Cloud is not supported by this tool (no client) — only Posit Connect and Snowflake/SPCS write .posit. shinyapps.io / Posit Cloud stay legacy-JSON-only (as Publisher also writes no .posit for them).
  • No change to file selection without a hand-curated files list. Two revisions of this PR got this wrong and were fixed:
    • Config-less deploys briefly honored .gitignore. Reverted — .gitignore is the wrong signal for bundling: a Quarto project's rendered HTML is routinely gitignored precisely because it shouldn't be committed, yet it's exactly what needs to deploy. Narrowing the default file set is a larger behavior change than .posit interop and belongs in its own PR.
    • A config minted by deploy Wording fix for the list command in the CLI #1 recorded the concrete deployed file set, which deploy URL format should infer http(s) #2 then read back as curation — silently dropping modules added in between. Now it records files = ["*"], and an unrestricted list imposes no restriction.
      Verified by capturing the tarball member list for a first deploy of a plain project on this branch and on main: byte-identical. A second deploy adds only the two .posit files.
  • Out of scope (follow-up Apply .posit/publish config Connect-side settings: post-deploy hook + rsconnect sync #831): a config's Connect-side settings that Publisher applies via the Connect API rather than the manifest — description, connect.runtime.*, connect.access.*, connect.kubernetes.*, and environment/secrets — are preserved on disk but never read for behavior; redeploy only ever carries forward entrypoint, requirements, title, and the content id, and doesn't issue the Connect API calls (PATCH content, env vars) that would apply the rest — whether or not they were just edited in the TOML. This is a no-op, not a clobber: the live Connect deployment is left exactly as it was, never reset to a default. redeploy now prints a warning when a resolved configuration sets any of these, so an edit isn't silently swallowed with no feedback. Full application is tracked in Apply .posit/publish config Connect-side settings: post-deploy hook + rsconnect sync #831.
  • The uv.lock diff is large but only adds tomli-w/pathspec (a reserialization; nothing removed or version-bumped).

Testing

  • tests/test_publisher.py — type-map coverage (incl. TensorFlow → unknown), round-trips, idempotent redeploy, URL normalization, a Publisher-authored interop fixture, connect_cloud round-trip, naming methodology, config/record pinning, that a written config's files reference the record actually written, bundle_url round-tripping (including surviving a redeploy that doesn't set it), the settings-warning helper (unhonored_redeploy_settings), and that two apps in one project deployed to the same server get distinct, non-colliding records.
  • tests/test_redeploy.py — command registration, identity reuse, config-name selection, legacy fallback, credential matching by URL (incl. ambiguous → error), and tarball-level assertions that redeploy bundles exactly the config's files, matches deploy's bundle, stays stable across repeated redeploys, and — for a project with no .posit — that a second deploy still bundles a later-added module and gitignored rendered output.
  • tests/test_publisher_files.py — config allowlist matching (rooted vs. basename, ! exclusion, dir includes, **, STANDARD_EXCLUSIONS, venv/renv skipping), the create_file_list restriction + builder-exclude interaction, resolve_bundle_files returning None without a config (and a gitignored build artifact still bundling) and for an unrestricted config (absent / ["*"] / ["*"]+.posit paths), restriction for a genuinely curated config, entrypoint force-include, and integration_requests propagation into the manifest (incl. an end-to-end executor test).
  • tests/test_deploy_pyproject.py — updated so the "unsupported app_mode" coverage still exercises a genuinely unhandled mode (R Shiny) now that Dash/Bokeh/Gradio/Panel/HTML/Node.js are supported, using a synthetic aliased-name case to keep covering that an unsupported alias fails quoting the configured string, not its resolved target.
  • Full suite green (843 passed, 12 skipped); ruff format/ruff check clean.

Try it

uvx --from "git+https://github.com/posit-dev/rsconnect-python.git@feat/publisher-toml-support" rsconnect redeploy --help

🤖 Generated with Claude Code

@mconflitti-pbc

Copy link
Copy Markdown
Contributor Author

Follow-up work is tracked in #831: applying a .posit/publish config's Connect-side settings (description, connect.runtime.*, connect.access.*, connect.kubernetes.*, environment, secrets) that Connect applies via API rather than through manifest.json. This PR already propagates the manifest-side fields (including integration_requests); #831 covers a post-deploy hook plus an ad-hoc rsconnect sync command to push config changes to a chosen (or config/record-default) server.

@mconflitti-pbc
mconflitti-pbc force-pushed the feat/publisher-toml-support branch from 5fa320b to 3f7218b Compare July 29, 2026 19:18
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://posit-dev.github.io/rsconnect-python/pr-preview/pr-830/

Built to branch gh-pages at 2026-08-06 19:49 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@mconflitti-pbc mconflitti-pbc changed the title Support Posit Publisher .posit/publish TOML interop + redeploy Posit Publisher .posit/publish: TOML interop, redeploy, and config-driven bundling Jul 29, 2026
@mconflitti-pbc
mconflitti-pbc marked this pull request as ready for review July 29, 2026 19:20
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
8516 7178 84% 0% 🟢

New Files

File Coverage Status
rsconnect/publisher/init.py 100% 🟢
rsconnect/publisher/config.py 95% 🟢
rsconnect/publisher/files.py 98% 🟢
rsconnect/publisher/record.py 98% 🟢
rsconnect/publisher/schema.py 100% 🟢
rsconnect/publisher/serialize.py 95% 🟢
rsconnect/publisher/store.py 91% 🟢
TOTAL 97% 🟢

Modified Files

File Coverage Status
rsconnect/api.py 82% 🟢
rsconnect/bundle.py 86% 🟢
rsconnect/main.py 81% 🟢
TOTAL 83% 🟢

updated for commit: c3e77d6 by action🐍

@nealrichardson

Copy link
Copy Markdown
Contributor

Behavior change: deploys without a .posit config now honor .gitignore when selecting files (previously only the built-in ignore list was applied). Called out in the CHANGELOG.

I'm not sure that's what we want, the reason IIRC that there is a separate tracking of included/excluded files is that for rendered content, you might gitignore the build output (the html that a quarto project produces, for example) because you don't want to check it in, but might want to deploy exactly that. That's at least why .gitignore wasn't just used before.

It's possible that that's not the right tradeoff, and that there's a better way to solve for that. But I wouldn't just casually do that here, I think that's a more significant behavior change.

@mconflitti-pbc

Copy link
Copy Markdown
Contributor Author

Behavior change: deploys without a .posit config now honor .gitignore when selecting files (previously only the built-in ignore list was applied). Called out in the CHANGELOG.

I'm not sure that's what we want, the reason IIRC that there is a separate tracking of included/excluded files is that for rendered content, you might gitignore the build output (the html that a quarto project produces, for example) because you don't want to check it in, but might want to deploy exactly that. That's at least why .gitignore wasn't just used before.

It's possible that that's not the right tradeoff, and that there's a better way to solve for that. But I wouldn't just casually do that here, I think that's a more significant behavior change.

Yeah that is fair! Will revert that

@amol-

amol- commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

I'm a bit confused about the interactions with deploy manifest, deploy pyproject and redeploy,
they more or less seem to target reproducible deploys and it's not immediately clear when a user should use one or the other.

@nealrichardson

Copy link
Copy Markdown
Contributor

Why do we need redeploy at all? Why isn't deploy sufficient?

@mconflitti-pbc

Copy link
Copy Markdown
Contributor Author

Why do we need redeploy at all? Why isn't deploy sufficient?

to avoid breaking changes/behavior with existing deploy commands. Could try to trigger redeploy behavior with empty deploy command but figured we could avoid that.

@mconflitti-pbc

mconflitti-pbc commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

I'm a bit confused about the interactions with deploy manifest, deploy pyproject and redeploy, they more or less seem to target reproducible deploys and it's not immediately clear when a user should use one or the other.

That is fair. Was punting on UX improvements of existing commands if I can help it. If you have ideas, I am open to them!

@amol-

amol- commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why do we need redeploy at all? Why isn't deploy sufficient?

I think because when users say "I want to deploy",
rsconnect answers: "great! And how do you want to deloy?"

  1. via the automatic configuration (IE: rsconnect deploy fastapi)
  2. via the configuration provided in the manifest.json (rsconnect deploy manifest)
  3. via the configuration provided in the pyproject.toml (rsconnect deploy pyproject)

and now there would be

  1. via the configuration provided in .posit directory (redeploy/deploy publisher?)

that's why I was wondering how we can ease reasoning about this for users.

Especially seems it looks like that this PR would always introduce the .posit directory, and thus from that point onward you would have two different commands you could use to redeploy

Read and write Publisher's .posit/publish config + deployment record files
alongside the legacy rsconnect-python JSON store, so content can be published
with either tool.

- New rsconnect/publisher package (schema, serialize, config, record, store)
   porting Publisher's format: content-type map, $schema-first TOML with
  multiline arrays, and the random base-32 file-naming methodology.
- Dual-write .posit on Connect/SPCS deploys via save_deployed_info (best-effort).
- New 'rsconnect redeploy [PATH]' command driven by .posit, with a fallback to
  manifest.json + legacy rsconnect-python/*.json for pre-.posit content.
- 'write-manifest' commands also emit a .posit config.
- Connect Cloud files are read/preserved for interop but not deployable here.
- Adds tomli-w dependency; tests in tests/test_publisher.py and test_redeploy.py.
save_deployed_info re-derived the config/record filenames independently of what
redeploy resolved, so when the resolved record lacked a configuration_name (or
its entrypoint differed from the bundle), a fresh <title>-<CODE>.toml config was
minted instead of updating the resolved one.

redeploy now threads the resolved config_name and record_name through the
executor to write_deployment_metadata, which uses them to update those exact
files. Adds tests covering the pin and the threading.
Publisher keeps its own credentials in VS Code SecretStorage, which a CLI
cannot read, and a .posit record stores only server_url (never the key). So
redeploy now matches the record's server_url against rsconnect-python's own
saved servers (ServerStore) by normalized URL -- the same join Publisher uses --
and deploys under that nickname when the caller gave no explicit credential.
Ambiguous matches (>1 saved server for the same URL) raise, asking for --name.

Also fixes normalize_url to strip a trailing slash before the /__api__ suffix
so '.../__api__/' compares equal to the base URL.
When a .posit/publish config applies to the content, deploys now bundle
exactly the files its `files` patterns select (gitignore-syntax, include
by default, `!` excludes), matching Posit Publisher's bundler. Without a
config, deploys now honor .gitignore in addition to the built-in ignore
list. A config rsconnect writes records the concrete deployed file set so
config, manifest, and record all agree.

- New rsconnect/publisher/files.py: pathspec-based select_config_files
  (allowlist) and select_default_files (.gitignore denylist).
- bundle.create_file_list gains include_files + a restrict_to_files
  contextmanager; the executor resolves the selection and wraps builders.
- store.resolve_bundle_files chooses config vs. default and force-includes
  the entrypoint; _config_file_patterns now emits the concrete, root-anchored
  list plus the .posit config/record paths (mirrors Publisher).
- Add pathspec dependency.
A .posit/publish config can declare integration_requests that rsconnect
cannot originate itself. Connect reads these from manifest.json (as Posit
Publisher's manifestFromConfig does), so when a config applies its
integration_requests are now merged into the generated manifest.

- bundle.py: overlay_manifest contextmanager + _apply_manifest_overlay,
  merged in Manifest.__init__ (the single manifest choke point;
  make_manifest_bundle stays manifest-driven). ManifestData gains
  integration_requests.
- store.py: config_manifest_overlay maps a config's integration_requests to
  Publisher's manifest shape; resolve_manifest_overlay + _select_applicable_config
  (extracted from resolve_bundle_files).
- api.py: RSConnectExecutor resolves file selection + manifest overlay together
  and wraps the builder in both contexts.
A function return annotation used the PEP 585 builtin generic dict[...],
which Python 3.8 cannot subscript at definition time, breaking collection of
the whole suite on 3.8. Add 'from __future__ import annotations'.
The integration-test CI job exports CONNECT_SERVER/CONNECT_API_KEY, which leaked
through the --server/--api-key env-var options into the redeploy CLI under test
and overrode the .posit-record-based server/identity resolution (6 failures, e.g.
deploy server resolving to http://localhost:3939 instead of the record's URL).
Add an autouse fixture that scrubs the CONNECT_* credential/server env vars so
these tests resolve from the record as intended.
Three defects in how a deploy's ``.posit`` metadata is written and read back,
all of which made a subsequent ``redeploy`` bundle the wrong file set:

- ``write_deployment_metadata`` recomputed ``rname`` after using it to build the
  config's ``files``. When no record existed yet, ``_new_record_name`` minted a
  second random name, so the config's ``files`` referenced a deployment record
  that was never written -- and the record that *was* written never shipped in
  the next bundle.
- ``_config_file_patterns`` anchored the manifest's ``metadata.entrypoint`` even
  when it is a module reference rather than a path (``deploy shiny`` records
  ``app`` for ``app.py``), seeding configs with a ``/app`` include that matches
  nothing. It now only surfaces the entrypoint when it names a deployed file,
  and still leads the list in that case.
- ``resolve_bundle_files`` treated a config with an empty/absent ``files`` list
  as "no config", falling through to the ``.gitignore``-aware whole-tree default.
  Publisher's ``collectFiles`` defaults such a list to ``["*"]``; match that, so
  the config governs and STANDARD_EXCLUSIONS still apply.

Adds regression coverage that asserts ``redeploy``'s actual bundle members
(the existing redeploy tests stub out ``make_bundle``, so they never saw the
file list): only the config's files are bundled, ``redeploy`` and an equivalent
``deploy`` agree, and repeated redeploys stay stable.
Config-less deploys now bundle exactly as they always have: the
whole-tree walk with only the built-in ignore list.

.gitignore is the wrong signal for bundling. Rendered content is
routinely gitignored precisely because it shouldn't be committed --- a
Quarto project's HTML output, for example --- yet that output is exactly
what needs to deploy. That is why bundling tracked included/excluded
files separately in the first place. Narrowing the default file set may
still be worth doing, but it is a larger behavior change than .posit
interop and belongs in its own change.

The config-driven path is unaffected: when a .posit/publish config
applies, its `files` list still decides the selection.
Drops `select_default_files` and `_gitignore_spec`;
`resolve_bundle_files` now returns None when no config applies, which
`restrict_to_files` already treats as a no-op.
A project that never had .posit was still changing behavior on its
*second* deploy. Deploy #1 writes a config recording the concrete set of
files it deployed; deploy #2 finds that config and treats the snapshot as
user curation. A module added in between, or output rendered in between,
silently stopped being bundled --- no warning, no diagnostic.

rsconnect can't know which files a user *meant* to exclude, so it no
longer guesses: a config it mints records files = ["*"], and
resolve_bundle_files treats an absent/empty/["*"] list (ignoring the
.posit paths Publisher adds so they ship) as "no restriction", falling
through to the unchanged whole-tree walk.

Hand-curated files lists are still honored --- that's the actual feature.

Verified a first deploy of a plain project selects a byte-identical file
list on this branch and on main, and that a second deploy adds only the
.posit files themselves.
Covers the interop workflow end to end: rsconnect deploys (writing
files = ["*"]), the user narrows the list to three files in Publisher,
then rsconnect redeploys. Asserts the curated list is honored by both
`redeploy` and a plain `deploy`, and that rsconnect does not overwrite
the curation with its own default.

Also pins two `files` shapes that could plausibly regress:
  - curated patterns alongside the .posit paths Publisher appends --- the
    .posit entries must not make the list read as unrestricted
  - ["*", "!secrets.txt"], which is curation by exclusion and must not be
    flattened away by the "* means everything" check

CHANGELOG now states the round-trip contract explicitly.
test_no_config_bundles_gitignored_files asserted a hardcoded
forward-slash path, but create_file_list's whole-tree walk returns
os.path.relpath results, which use the native separator. Failed CI on
windows-latest (py3.13) with backslash paths; passed everywhere else.
Publisher's Go backend rejects a redeploy with "the account provided is
for a different server; it must match the server for this deployment"
whenever a record's server_url isn't byte-identical to the selected
account's URL (internal/state/state.go: `target.ServerURL !=
account.URL`, a plain string comparison -- no normalization at compare
time). Publisher's own writers always store the account's URL, which is
normalized via purell (FlagsSafe | RemoveTrailingSlash |
RemoveDotSegments | RemoveDuplicateSlashes) whenever a credential is
created or loaded.

rsconnect-python was writing server_url verbatim -- whatever string the
user typed for --server or saved via `rsconnect add`, e.g. with a
trailing slash, mixed-case host, or an /__api__ suffix. A project
deployed first with rsconnect-python and then opened in Publisher could
therefore fail with a server-URL mismatch even though Publisher
correctly resolved the intended account by name (visible in Publisher's
access log as req.account=<name>) -- the account was right, but the
stored URL didn't match its normalized form byte-for-byte.

Fix: write normalize_url(server_url) into the record instead of the raw
value. Also brought normalize_url's own normalization up to match
purell's rules exactly -- it was already stripping /__api__ and
lowercasing scheme+host, but didn't strip an explicit default port
(:443 for https, :80 for http) or collapse duplicate slashes in the
path, both of which purell does.

Verified: test_written_server_url_matches_publisher_normalization pins
Publisher's exact set of cosmetic variations (case, trailing slash,
default port, duplicate slashes, /__api__ with and without a trailing
slash) against the values rsconnect-python now writes.
rsconnect-python's Python deploys record metadata.entrypoint as a bare
importable module reference ("app" for app.py, sometimes "module:object")
rather than a path. That value flowed unmodified into a written
.posit/publish config's entrypoint field, even though Publisher's schema
documents entrypoint as "Name of the primary file containing the
content", and Publisher's own detectors (pyshiny.ts, pythonApp.ts) always
record the literal filename for Shiny/Flask/FastAPI/Dash -- only Shiny
Express writes a module:object reference, and that module never names a
real file.

_recover_entrypoint_path (record.py) strips a trailing ":object" and
checks whether "<module>.py" is one of the manifest's actual files; if
so that's the real, verified path to record. Left unchanged when no
matching file exists, so a genuine module:object reference (Shiny
Express, or an explicit --entrypoint override with no corresponding
file) is untouched. Verified against Publisher's own detector source
(posit-dev/publisher) that Flask/FastAPI/Dash/plain-Shiny all expect the
literal filename and only Shiny Express uses shiny.express.app:<var>.

Sits in details_from_manifest, the single choke point both the deploy
write path and write-manifest go through.
Widen redeploy/deploy-pyproject dispatch to Dash, Bokeh, Gradio, Panel,
HTML, and Node.js (R stays out of scope: rsconnect-python has no R
bundle builder). Warn, rather than silently no-op, when a resolved
.posit config sets description/environment/secrets/connect.* settings
that redeploy cannot yet push to Connect. Fix two data-loss bugs: an
existing config's [r] table was dropped on rewrite, and a record's
bundle_url field was declared managed but never modeled, so it
vanished on the first rewrite. Disambiguate deployment-record matching
by content GUID so two apps on one project deployed to the same server
no longer collide onto a single record.
@mconflitti-pbc
mconflitti-pbc force-pushed the feat/publisher-toml-support branch from 60c2c58 to c3e77d6 Compare August 6, 2026 18:33
@mconflitti-pbc

Copy link
Copy Markdown
Contributor Author

@nealrichardson i reverted the gitignore change.

@amol- re: the new command

adding a parallel path allows this to be added and used by our agentic deployment skill alongside projects that have been deployed using publisher or vice versa. the goal of this pr was not to change the fundamental structure of the deploy command.

definitelty open to folding that behavior into the deploy command in a follow up.

there are other things to address as well regarding how we want to rsconnect-python to handle settings which are tracked in #831

Publisher's redeploy preflight (connectPublish.ts) checks for
requirements.txt/environment.yml by literal pattern.endsWith(name)
against a config's files, rather than expanding glob patterns the way
its own bundler does. rsconnect-python's default files = ["*"] already
selects that file for bundling, but fails Publisher's own redeploy
because "*" never matches the endsWith check.

write_deployment_metadata and write_config_from_manifest now also
append the literal, root-anchored package-file path (from the
manifest's python.package_manager.package_file) to a freshly minted
config's files, alongside "*". This changes nothing about what
actually bundles.

_is_unrestricted is taught that a bare "*" plus only non-negated
literal entries is still "everything" -- a !-free pattern list
containing "*" can never select less than everything, so the extra
literal is redundant for selection purposes. Without this, the next
deploy would misread the appended literal as user curation and switch
from the whole-tree walk to the config-driven selector, changing
default file selection for content that was never curated.
Reformats each entry to short, active-voice sentences and adds section
headings, and documents the Python package-file redeploy fix as a
single paragraph alongside the other Unreleased entries.
Nothing in the Unreleased section has shipped yet, so there is no
prior release for this to have been broken in -- it is part of
building the .posit interop feature correctly the first time, not a
fix to it.
Comment thread docs/CHANGELOG.md
shinyapps.io deploys it also skips opening a browser.
### Added

- Python 3.14 support. The test suite now runs on Python 3.14 in CI.

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.

(I know this isn't from your PR but it's in the diff) but is it true that we "added" support for Python 3.14? Or did we just add CI? If the latter, then we should delete this line.

Comment thread docs/CHANGELOG.md
record (`.posit/publish/deployments/<name>.toml`) next to the existing
`rsconnect-python/` metadata, and reads and preserves configurations and
records that Publisher wrote. One project can publish with either tool.
- `rsconnect redeploy [PATH]` command. It redeploys content from an existing

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.

To my earlier question: one thing that sounds odd to me about adding a redeploy command is that we already can re-deploy. rsconnect-python records somewhere where it has previously deployed, so rsconnect deploy fastapi . for example will use the same content guid as the last time. With this change, does it mean that rsconnect deploy fastapi . and rsconnect redeploy . will both re-deploy but go through distinct code paths?

Why instead not have just rsconnect deploy ., and (in the case here where there is no pseudo-appmode given) it first looks for the TOML file, then falls back to looking for the old rsconnect-python record, etc.? Since the appmode is immutable on Connect, you don't need to specify it when you're re-deploying content, it must be whatever it already is on Connect so you can query it from the API. (That's what connect-actions does.) So even without a publisher TOML you can reconstruct enough to deploy with just the content GUID (from the rsconnect-python record, or as an argument).

Comment thread docs/CHANGELOG.md
entrypoint, requirements, title, and content ID from a configuration; if
the configuration sets `description`, `environment`, `secrets`, or
`connect.*`, it prints a warning and deploys anyway, since applying those
settings needs a Connect API call `redeploy` does not yet make.

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.

"yet" implies that it will, and I'm not sure it should.

Comment thread docs/CHANGELOG.md
- Read support for Connect Cloud (`connect.posit.cloud`) `.posit` files.
rsconnect-python reads and preserves these files for interoperability, but
does not support deploying to Connect Cloud.
- File curation from a `.posit/publish` configuration's `files` list. When

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.

These bullets sound more like documentation about how it works, not separate changelog entries.

@@ -0,0 +1,175 @@
"""The ``.posit/publish/<name>.toml`` configuration file (the "what").

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.

what "the 'what'"?

@dotNomad dotNomad left a comment

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.

Looking at how this is setup I think it could cause some confusion.

My read is that deploy will not use .posit/publish files to determine what or how to deploy, but will write the .posit/publish files.

The redeploy command will use and write the .posit/publish files.

But there is a pretty large difference in how re-deployment and --new work. rsconnect will, when targeting the same server, will forget about previous deployments - only storing one content item per-server. That will cause the .posit/publish files to have a single configuration and two deployments - that is totally fine, Publisher handles that and it is fully expected however rsconnect doesn't play nicely there.

rsconnect deploy will use the new deployment and rsconnect redeploy will error because it doesn't know which deployment of the two to use. If you remove one of the deployment records you could have rsconnect deploy going to the new content item and rsconnect redeploy going to the old which is very confusing.


I think it would be best for us to sync about this and come up with a minimal path forward to get the behavior, but not try to re-design rsconnect too much.

My preference would be to use .posit/publish configuration and deployment files, but mimic the rsconnect behavior of only having one deployment per server to start. It means that you cannot easily have two content items on a server like you can with Publisher, but it avoids some real confusing cases like the one above and reduces the need, initially at least, of the redeploy command.

@amol-

amol- commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

rsconnect deploy will use the new deployment and rsconnect redeploy will error because it doesn't know which deployment of the two to use. If you remove one of the deployment records you could have rsconnect deploy going to the new content item and rsconnect redeploy going to the old which is very confusing.

What if we made a new command for real? "reconnect publish". That relies on the new .posit/publish files for both first and subsequent deploys, has a predictable behavior fully separate from deploy and avoids polluting the deploy command behavior itself which causes confusion

A parallel deploy path we can describe as the one for people using both reconnect and the publisher to deploy the same content.

@nealrichardson

nealrichardson commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What if we made a new command for real?

If we were to do that, we're basically making a v2 deploy CLI, and I would advocate for doing that in posit-cli. Most of the code could still live here, if that were easier, but if we're going to teach a new command for deployment, then we should be definitely be moving off of calling it rsconnect.

I'm not trying to put extra requirements on here to stop forward progress, but if that's the direction we're headed, let's just do it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants