Skip to content

Monorepo Publishing

dart pub publish cannot publish a package that has path: dependencies on other packages in the same repository. Validators reject path deps because they cannot be resolved by anyone outside the workspace — the package wouldn’t be installable.

For monorepos and pub workspaces, the standard fix is tedious: rewrite each pubspec by hand to use hosted references, publish in dependency order, then revert. Club ships two commands that automate this:

  • club prepare: rewrites selected packages’ on-disk pubspecs in dependency order, but does not publish.
  • club publish --auto: does the same rewriting in memory and publishes only selected packages, with source files left exactly as they were before.

This guide is the end-to-end story.

When to use which

club publish --auto

Default choice for releases. One command from picker to last package on the server. Source files never modified.

club prepare

Use when you want the rewrites to land on disk first — for review, commit to a release branch, or because your CI publishes package-by-package via dart pub publish rather than club’s flow.

Both commands share the same discovery, dependency graph, conflict resolution, and tree visualisation. They differ only in what they do with the planned rewrite:

  • prepare writes to disk.
  • publish --auto keeps it in memory and uses it for the upload only.

The model

A monorepo with internal deps is modelled as a directed acyclic graph (DAG). The canonical example used throughout this guide:

┌─────────────────────┐
│ core_interface │ v1.0.0 (no internal deps)
└──────────┬──────────┘
│ depended on by
▼
┌─────────────────────┐
│ core │ v1.2.0
└──────────┬──────────┘
│ depended on by
▼
┌──────┴──────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ pkg_a │ │ pkg_b │ ◀ user-selected targets
│ v0.5.0 │ │ v0.3.0 │
└─────────┘ └─────────┘
Cross-edges (not drawn):
pkg_a → core_interface (also a direct dependency)
pkg_b → core_interface (also a direct dependency)

pkg_a and pkg_b are the leaf packages the developer wants on the server. core is an internal dependency they share. core_interface is a deeper internal dep that core, pkg_a, and pkg_b all consume. Three levels, five edges total.

When you target pkg_a and pkg_b:

  1. Discover workspace packages so internal dependencies can be identified.

  2. Inspect selected packages’ direct dependencies and dev_dependencies, without adding those dependencies to the selection.

  3. Require core 1.2.0 and core_interface 1.0.0 to exist upstream. Missing versions or failed lookups stop before publication or local edits. These unselected dependencies never prompt for overwrite or get uploaded.

  4. Rewrite internal references only in pkg_a and pkg_b to the target server, using the dependencies’ local versions:

    # before
    core_interface:
    path: ../core_interface
    # after
    core_interface:
    hosted: https://club.example.com
    version: ^1.0.0
  5. Publish only pkg_a and pkg_b, or prepare only their local pubspecs. Unrelated packages and local dependencies behind reused packages are not checked for publishing.

To publish or prepare dependencies too, select them explicitly:

Terminal window
club publish --auto core_interface core pkg_a pkg_b
club prepare core_interface core pkg_a pkg_b

With all four selected, core_interface publishes first, then core, then pkg_a and pkg_b.

Topological order is critical: by the time package N is uploaded, every hosted dep it references is already on the server, so the validator’s dart pub get step can resolve them.

Internal-dep detection

Both path: deps and hosted-by-name deps are detected and rewritten:

packages/pkg_a/pubspec.yaml
dependencies:
core:
path: ../core

Detected because the path resolves to another discovered package.

dependency_overrides is intentionally never rewritten. That section is local-only and should not appear in published pubspecs.

External path: dependencies (paths that resolve outside the discovered set) are surfaced as a hard error before anything happens. That matches the DependencyValidator behaviour in standard club publish.

Version conflicts

Both commands check selected packages for conflicts at their effective versions. --on-conflict applies only to that selection. Unselected direct dependencies are reused at their exact local versions and never uploaded or presented as conflicts. Selected-package conflicts have three options:

OptionWhat it does
OverwriteForce-publish, replacing the existing version. The on-the-wire upload sets force=true.
SkipReuse the already-published version as-is. The skipped package is not rewritten/republished; dependents still rewrite to point at the existing published version.
AbortExit cleanly without publishing or writing anything.

Without flags, both commands prompt per conflict via an arrow-key picker. Pass --on-conflict <mode> to make the choice global:

Terminal window
# Force overwrite all conflicts (for example: re-publishing a known-good build)
club publish --auto --on-conflict overwrite
# Reuse the existing version anywhere it's already on the server
club publish --auto --on-conflict skip
# Fail fast if any version is already on the server (best for CI)
club publish --auto --on-conflict abort
# Per-conflict prompt (default)
club publish --auto --on-conflict prompt

The two flows

The two commands share the same dependency analysis. They differ in when and where the rewrite lands:

Source pubspec.yaml state
─────────────────────────────────
before during after run
club publish --auto unchanged → unchanged → unchanged
(rewrite is in-memory only;
uploaded tarball carries it)
club prepare unchanged → modified → modified
(rewrite is written to disk;
committable artifact)
club prepare + manual revert unchanged → modified → unchanged
(workflow: prepare, publish per
package, then `git restore`)

One command, source files never touched, no commit churn:

Terminal window
$ club publish --auto core_interface core pkg_a pkg_b
🚀 club publish --auto
...
Publish 4 packages to https://club.example.com? [y/N] y
[1/4] Publishing core_interface … 🎉
[2/4] Publishing core … 🎉
[3/4] Publishing pkg_a … 🎉
[4/4] Publishing pkg_b … 🎉
🎉 4 packages published

The rewrites happen entirely inside the uploaded tarballs:

  • Validators see the rewritten dep shape (no path-dep error).
  • The tarball ships the rewritten pubspec.
  • The on-disk file is never read or written.
  • dart pub get keeps resolving locally throughout because pub workspace shadowing handles the on-disk path/hosted-by-name deps.

Flow B — club prepare then club publish per package

Use this when the rewritten pubspecs are an artifact you want to commit or review:

Terminal window
# 1. Rewrite the on-disk pubspecs in topological order.
club prepare core_interface core pkg_a pkg_b
# 2. Inspect the diff.
git diff packages/
# 3. Commit (and tag, if you're cutting a release).
git add packages/*/pubspec.yaml
git commit -m "Prepare release"
# 4. Publish selected packages in dependency order.
cd packages/core_interface && club publish -f
cd ../core && club publish -f
cd ../pkg_a && club publish -f
cd ../pkg_b && club publish -f

You can also use --auto against a workspace that has already been prepared — it simply detects no rewrites are needed and runs the publish loop in order.

Versioning patterns

Neither command bumps versions for you. Pick one of:

Edit version: in each package’s pubspec, commit, then run --auto. Suitable for small monorepos where releases are infrequent.

CI patterns

.github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- name: Install club CLI
run: curl -fsSL https://club.birju.dev/install.sh | bash
- name: Publish monorepo
run: |
club publish --auto \
--force \
--on-conflict abort \
--server myclub.birju.dev
env:
CLUB_TOKEN: ${{ secrets.CLUB_TOKEN }}

CLUB_TOKEN shadows any stored credential — set it from a CI secret and you don’t need a separate club login step.

Tarball size pre-flight

Both commands measure each package’s tarball before any write or upload happens. This catches the most common monorepo gotcha — a package whose archive exceeds the server’s size limit (default 100 MiB) — before the run gets halfway through.

The size shows up inline in the dependency tree:

▶ [3] pkg_a ◀ target 0.5.0 ✓ publish · 24.1 KiB
depends on ↑ core ^1.2.0, core_interface ^1.0.0

A package whose tarball would fail the size check is flagged here, with no partial publish having taken place. Re-run with --no-tree to see the same info as a flat table instead.

Troubleshooting

<name>: dependencies.<dep> is a path dependency pointing outside the workspace

A path: dep resolves to a directory that’s not in the discovered set. Either move that target into the workspace, or replace the dep with a hosted reference manually.

<name> has no version field in pubspec.yaml

A package referenced as a rewrite target has no version: in its pubspec. Add one — even private packages need a version for downstream constraints to resolve.

Dependency cycle: a -> b -> a

The dep graph contains a cycle. Replace one of the path deps in the cycle with a hosted reference (or refactor the cycle out).

Version conflicts detected but stdin is non-interactive

You ran in CI without --on-conflict. Pass --on-conflict <mode> explicitly so the behaviour is deterministic.

A publish failed mid-chain

--auto aborts immediately and prints the packages already on the server. Re-running is safe:

  • Pass --on-conflict skip to bypass the already-published packages and pick up where the chain left off.
  • Or --on-conflict overwrite if you want to re-publish them too.