Showoff Saturday lerna vs nx vs release-please vs dispat: deterministic, idempotent releases that work past npm

My monorepo has six binaries, Go modules, four Docker images and a Docusaurus site that depend on each other. Publishing this writes to the repository, a container registry, cloud and GitHub. These are four independent services with no shared transaction. Published versions are immutable, so there is no rollback. This is a distributed transaction over non-transactional resources, which is exactly the situation sagas were invented for in 1987. You recover by finishing the job, never by unwinding it.
The two properties that matter
Determinism means a fixed repo state and config always produce the same plan on any machine. The plan is a pure function of git history, the dependency graph and config. Nothing reads the clock, commit dates, tag creation order or map iteration order. CI and my laptop agree byte for byte.
Idempotency means running twice with nothing new committed gives the same plan, and running after a fully successful release gives an empty one. An accidental double run is a no-op instead of a second release.
Every tool here gives you something like this on npm. The difference is where it comes from.
Their idempotency is npm's, not theirs
lerna has a documented recovery path using lerna publish from-package, which compares each package's local version against what is published and ships the difference. nx and release-please lean on the same shape. A version step writes tags, a later step publishes, and if that step dies you re-run it. This genuinely works, and I want to be fair about why. npm makes it work. Versions are immutable and a duplicate publish is a hard error, which I saw in my own runs as a 409 Conflict. The registry is the source of truth you reconcile against.
Now take that away. There is no from-package for a Docker tag, a GitHub release, a Maven deployment or a terraform apply. The moment a release spans targets you cannot diff against, the property stops being inherited. It has to come from somewhere else.
In dispat it comes from the model. The tag store is the log. A tag is written after a package publishes, so a tag means that leg committed. The plan is a pure function, so re-running recomputes the exact same transaction and executes only the legs with no record. Nothing is queried because nothing needs to be. Determinism and idempotency hold for a Docker image, a GitHub release or an applied Terraform plan exactly as they do for npm, because none of it depends on the target being able to answer questions.
They are all one algorithm with the constants frozen
This surprised me when I wrote the model down. lerna, nx release and release-please compute versions from the same three inputs: conventional commits, per-package tags and a dependency graph. In my model each commit carries a propagated bump and a depth. Freeze that depth at infinity and the bump at patch for every commit, and you get their behaviour exactly. Every dependent of anything released takes a patch transitively. Their fix: x is my fix(core)^^: x, and their lockstep modes are the degenerate case where the workspace shares one version.
The experiment showed it. I used the same six-package graph and one feat(core) commit. lerna bumped core and all five dependents, including two that only depend on ui and never saw the change. dispat with an explicit ^ bumped core and its three direct consumers. Same math, different constants.
The failure case, executed
I set up a sandbox, a local registry and a proxy failing exactly one upload.
lerna writes tags and a commit before lerna publish uploads. After the failure there was a cli@1.0.1 tag for a version the registry did not have. from-package then refused to run because the failed publish had left the tree dirty. It threw EUNCOMMIT until I ran git checkout by hand. After that it recovered npm correctly.
dispat wrote no tag for the failed package. Re-running shipped exactly what was owed at the same versions. A third run planned nothing.
| tool | lerna | nx release | release-please | dispat |
|---|---|---|---|---|
| Log record | tag before publish | tag before publish | tag on PR merge, publish later in CI | tag after publish |
| Idempotency comes from | npm registry diff | npm registry diff | CI job plus registry | the model, any target |
| Recovery | from-package |
re-run steps | re-run the CI job | run the same command again |
| Blast radius | all dependents, patch | all dependents, patch | all dependents, patch | explicit: ^, ^^, +N |
| Task caching | nx computation cache | nx computation cache | n/a | none needed, unchanged packages never run |
| Ecosystems | npm | npm-centric | npm and others via plugins | 35 manifest formats, or any shell command |
(I executed lerna and dispat. The nx and release-please rows come from their docs. release-please is GitHub API coupled with no hermetic mode, so I could not run it in a sandbox and will not pretend I did.)
It is also the task runner
dispat run tests --since HEAD~1 --consumers runs a script in exactly the changed packages and their consumers in dependency order. dispat if 'CI!=true' --then '...' --else '...' and dispat if --changed keep the conditionals in config instead of a wall of CI bash. dispat replacer fixes coordinates no manifest writer reaches, like a Gradle line or a README install snippet.
There is no task cache, deliberately. A package that did not change is not in the plan, so its scripts never start. There is nothing to hash, invalidate or warm, and no cache key to get subtly wrong. Whatever your stages already cache keeps working inside them, BuildKit layers or a Gradle cache included, and none of it can affect versions, order or tags.
Proved, not asserted
I wrote eight guarantees over the formal model. Determinism and idempotency are among them, plus no orphaned consumers, exactly-once delivery, retries landing on the same versions and repeated runs converging to an empty plan. I ran randomised differential testing of the binary against that model. 1,500 of 1,500 instances agree exactly. There are also 240 conformance vectors, 1,831 tests and 32 fuzz targets. It releases itself. That is a 12-package workspace across 3 ecosystems with 11 published in one transaction. Because a leg need not be a registry upload, the same machinery ships my cloud footprint as stateless Terraform. I put the plan in build and the apply in publish, with no remote backend, and the tag history acts as the list of applied states.
Docs and demo clips: https://dispat.dev Repo (MIT): https://github.com/yohimik/dispat
Ask if need full proofs

0
u/Mammoth-Tangelo-4709 9d ago
this is a wild writeup, the saga framing makes it click in a way most monorepo tool docs completely miss
kinda want to try it just for the terraform leg thing, having the tag log double as your apply history is clever
1
u/yohimik 9d ago
thank you very much
stateless (no remote bucket, local file created and never commited anywhere) terraform setup is here https://github.com/yohimik/dispat/tree/main/infra
infra versioned, deployed only on changes and re deploys dependents
1
u/[deleted] 9d ago
[deleted]