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 the on-disk pubspecs in topological order, but does not publish.
  • club publish --auto — does the same rewriting virtually in-memory and publishes every package in one run, 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. Walk the workspace, find every pubspec.yaml with a name: field.

  2. Identify which discovered packages each target package depends on (transitively). The closure includes the targets and every internal dep.

  3. Topologically sort the closure — leaves first, dependents last. In the example above, that yields: core_interface, core, pkg_a, pkg_b.

  4. For each non-leaf package, rewrite its dependencies / dev_dependencies entries that point at workspace members so they become hosted references against the target server:

    # before
    core_interface:
    path: ../core_interface
    # after
    core_interface:
    hosted: https://club.example.com
    version: ^1.0.0
  5. Publish each package in topological order. core_interface first, then core (which now references core_interface ^1.0.0 from the server), 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

Before any rewrite is computed, both commands query the server for every package’s current published versions. For each package whose local pubspec version is already on the server, you 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 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 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 each package — order doesn't matter at this point because
# the pubspecs all reference hosted versions.
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.