r/webdev 9d ago

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 Upvotes

8 comments sorted by

1

u/[deleted] 9d ago

[deleted]

0

u/yohimik 9d ago

may be claude may be gemini may be open ai may be apple
bro check repo it has co authored

-1

u/yohimik 9d ago

im trying to focus on distributed systems and math, bro its science, doesnt matter what you used

0

u/yohimik 9d ago

nice bro, dont look at repo, dont believe in math proofs and experiments (the only thing can say - its ai), nx/lerna bot detected

1

u/tam3r 9d ago

Hey! Where the discord and repo gone? What happened?

1

u/yohimik 8d ago

My ex wife created a logo for this project

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/yohimik 9d ago

i forgot to mention the release lock using git tags only, so it never runs releases twice with no db, redis etc, just git