Skip to main content
dispat logo

dispat

tests workflow statusstatement coverage

dispat is a release tool for polyglot monorepos. It reads your conventional commits, works out which packages changed and what their next semantic versions are, propagates those bumps to dependants, then builds and publishes every package in dependency order, in parallel, writing changelogs, git tags and GitHub releases on the way out.

Polyglot is the point. A package is a folder and a stage is a shell command, so npm, Go, Cargo, Maven, .NET, Python, Ruby, Dart, Docker, iOS and Android sit in one dependency graph and release together.

One binary, one config file. No daemon, no state file and no cache to operate.

$ go install github.com/yohimik/dispat/services/dispat@latest
$ dispat init # starter dispat.json (--format yaml/toml)
$ dispat compute --write # derive the graph and starting versions from the manifests

$ git log --oneline -2
9f3c2a1 feat(core)^: add streaming api # ^ = also bump core's direct consumers
b82d47e fix(utils): close file handle leak

$ dispat status # dry run: the full plan, nothing touched
● changed package=core bump=minor version=1.4.2 -> 1.5.0
● changed package=api bump=patch version=0.8.2 -> 0.8.3 dueToProviders=[core]
● changed package=utils bump=patch version=2.0.3 -> 2.0.4
unchanged package=docs version=1.1.0
release plan ready packages=4 releasing=3

$ dispat # release: build + publish in graph order, in parallel
published package=utils tag=utils@2.0.4
published package=core tag=core@1.5.0
published package=api tag=api@0.8.3 # waited for core's publish
done published=3

$ dispat # re-running is always safe
done published=0 unchanged=4

(Output abridged. The starter config still needs two things from you: which folders hold your packages, and the build and publish commands to run in them.) If api's build had failed, core and utils would still have shipped, the run would exit non-zero, and the next run would release api at the exact version it was owed. Runs are self-healing, and that failure model is the point of the tool; Concepts explains it.

Why one more monorepo tool?

Every major monorepo tool can topologically sort a dependency graph: build everything in order, then publish everything, or publish only what changed, one package at a time. Two situations break that model in practice:

  1. An error in the middle of a run. Half the packages are published and half are not. Most tools either abort the whole run or plough on and leave you to reconstruct what shipped. Re-running tends to re-release what is already out, so you end up writing recovery scripts by hand.
  2. A consumer that can only be built once its provider is published. A Node package can be built before its consumers publish, but a Docker image is often buildable only by pulling its base image from a registry, which means the provider has to be published first. Build everything then publish everything assumes every ecosystem behaves like npm, and mixed graphs break it.

Modern projects are exactly that mix: many packages on different infrastructure, npm next to Docker next to Go, wired into one dependency graph. dispat is built for that case. Concepts works both situations through end to end.

Releases the graph, not a list

Consumer and provider ordering, parallel builds and publishes with separate concurrency budgets, and isBuildWaitingPublish for the ecosystems, Docker among them, where a consumer can only build once its provider is published.

Blast radius written in the commit

feat(core): releases core alone, ^ reaches its direct consumers, ^^ the transitive closure, and +N exactly N edges. Nothing is released on a guess.

Self-healing runs, because a release is a distributed transaction

Publishing a graph means irreversible writes across independent services with no rollback to fall back on, so each package's leg commits by durably recording its own completion: the annotated git tag, written only once the publish succeeded. A broken package skips only its true dependants while everything else keeps releasing, and re-running is the recovery: the plan is a pure function of history, graph and configuration, so the next run recomputes the same transaction and executes only the legs whose record is missing. No state files, no double releases and no repair scripts. Details.

Release control from commits

%beta starts a prerelease train and %beta>stable graduates it, Release-As: none holds a package and Release-As: auto resumes it, Release-As: 2.0.0 pins an exact version and cancel(pkg) discards pending work. It is written in commits, so release decisions are reviewed and versioned like code. Details.

Polyglot by construction: any language, any registry, any tooling

Stages are shell commands fed a rich DISPAT_* environment, release state lives in git tags, and dispat reads and rewrites twenty-three manifest formats across fifteen ecosystems, npm to go.mod to Podfile, so dispat compute can derive the dependency graph and each package's starting version from the repository you already have. And because an unchanged package is simply not in the plan, there is no task cache to manage, clear or distrust: BuildKit layers, an Nx, Turborepo or Bazel cache and the Gradle build cache all keep working inside the stage, and none of them can change what is versioned, ordered or tagged.

Every release step is also a command, with the records built in

Per-package changelogs, annotated tags, GitHub releases and an optional release commit, each also runnable alone (dispat changelog, commit, github) with the release stage finding the work done and skipping it. dispat status dry-runs the whole plan, a release lock refuses two releases of one repository at once, and dispat release -p core (or -s libs, -g platform) ships a subset at exactly the versions a full release would have given it, with dispat if, exec, autowriter and autoreplacer as the glue a custom pipeline is assembled from. Details.

Lightweight libraries, usable on their own

Parsing commit messages and reading and rewriting dependency manifests are problems far older than releases, so dispat keeps all three as standalone Go modules with no dependency on the CLI, on git or on a network. The manifest pair shares its vocabulary through pkg/manifest (dependency kinds, manifest file-name rules, PEP 503 normalisation) so the reader and the writer can never drift apart.

pkg/ccme: the commit parser

Conventional Commits, Monorepo Extension: a strict superset of Conventional Commits 1.0.0 that adds scopes as packages, propagation depth and prerelease channels. No regular expressions: one left-to-right index scan with a byte of lookahead, no backtracking, no recursion, O(n) time and O(1) working space, which is what matters when the input is untrusted commit messages in CI. The specification is vendored beside it as SPEC.md, and every section reference in the code points into it.

pkg/scanner: the manifest reader

Thin per-format parsers turning every manifest below into one ecosystem-neutral shape: declared identity, dependencies, ranges and local-path signals. No SBOM machinery, no lockfile resolution, no network; bounded reads, deterministic order, and a partial result even when one file fails to parse.

pkg/writer: the manifest writer

Format-preserving in-place edits for every manifest the scanner reads: only the version text being changed is replaced, and every other byte (indentation, key order, comments) survives verbatim. Writes are atomic (temp file, fsync, rename) and skipped when nothing changed, and the result separates what was applied from what was deliberately left alone, such as a value that defers to a Maven property or a workspace inheritance.

Languages and manifests the reader and the writer support

Language / ecosystemManifests read and rewritten
JavaScript, TypeScript: npm, pnpm, Yarnpackage.json
Gogo.mod
Rust: CargoCargo.toml
Python: PEP 621, PEP 735, Poetry, pippyproject.toml, requirements*.txt
PHP: Composercomposer.json
Java, Kotlin, Scala: Mavenpom.xml
C#, F#, VB: .NET, NuGet*.csproj, *.fsproj, *.vbproj, *.nuspec, Directory.Packages.props, packages.config
Dart, Flutter: pubpubspec.yaml
Ruby: Bundler, RubyGemsGemfile, *.gemspec
Swift, Objective-C: iOS, CocoaPodsInfo.plist, project.pbxproj, Podfile, *.podspec
Kotlin, Java: Android, GradleAndroidManifest.xml, libs.versions.toml, build.gradle(.kts)
Docker: images and ComposeDockerfile, Containerfile, compose.yaml, docker-compose.yml, and their .override spellings

The mobile formats also carry a build number beside their marketing version (CFBundleVersion, android:versionCode, CURRENT_PROJECT_VERSION): the scanner reads it, no version write ever moves it, and --set-build is the write that does. dispat compute derives a monorepo's dependency graph from these files, and autoVersion rewrites them at the version stage.

Install

One command, and no runtime to install first. The script downloads the binary for your platform, checks it against the checksum GitHub published, and puts it on your PATH.

Linux and macOS
curl -fsSL https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh
Linux and macOS, with wget
wget -qO- https://raw.githubusercontent.com/yohimik/dispat/main/install.sh | sh
Windows
irm https://raw.githubusercontent.com/yohimik/dispat/main/install.ps1 | iex

After that the binary keeps itself current: dispat self-update replaces it with the latest release and keeps the old one beside it for a week in case you want it back. Every command mentions a newer release on its way out, so you find out without going looking.

More ways to install (go install, the GitHub Action, the container images) and how to pin a version are in Getting started.

Projects using dispat

The first monorepo dispat releases is its own: every tag, changelog, GitHub release and container image of this project, and this documentation site, ship through a dispat run.

dispat itself: this repository is a polyglot Go, npm and Docker workspace released by the dispat binary built from its own checkout, and it has been since the very first release. The first stable run cut eleven packages in one release: the six Go modules with their go.mod files rewritten to the released versions, go.sum files regenerated and each module tagged the way Go expects (pkg/ccme/v1.0.0, services/dispat/v1.0.0), which is what keeps go install github.com/yohimik/dispat/services/dispat@latest working; six cross-compiled binaries attached to the CLI's GitHub release; the four container images; and the versioned documentation site, every package with its own tag, changelog entry and GitHub release.

webxash3d-fwgs: a WebAssembly port of the Xash3D-FWGS game engine. A real docker on docker on npm provider chain, four levels deep, with parallel builds from the engine package up to the modded server image.

The documentation

Inspiration

dispat stands on the shoulders of two things:

  • Lerna, and the workspaces of npm and pnpm it grew up beside. Between them they proved that many packages in one repository can share a dependency graph, and that versioning and publishing all of them can be one command. dispat takes that idea beyond JavaScript and rebuilds it around an explicit dependency graph and an explicit error model.
  • Conventional Commits: commit messages as machine-readable release intent. dispat's parser, pkg/ccme, implements a strict superset of Conventional Commits 1.0.0 that adds the monorepo dimension: scopes as packages, propagation depth and prerelease channels.

Have questions or issues?

Want to share a project you release with dispat? Come and say hello on Discord. Bugs and feature requests are welcome as GitHub issues too, whichever suits you better.