🚀 Mops v3 is out: pinned Motoko toolchain, one lockfile model, faster installs (and no dfx)

Mops v3 is released. It is the first major release since 2.0, and it is a breaking one. The headline is that Mops now owns and pins the entire Motoko toolchain, and that dfx support is removed. If you deploy with dfx, please read the dfx section before you upgrade.

npm i -g ic-mops

or, from an existing install:

mops self update --major

The latest release is 3.1.0. Node.js >= 22 is required.

:memo: TL;DR

  • Mops manages the whole Motoko toolchain (moc, pocket-ic, wasmtime, lintoko, wasm-opt) and every version is pinned in mops.toml. No silent defaults, no “latest release” lookups on the build path.
  • dfx support is removed. No --replica dfx, no dfx-bundled moc fallback, no mops toolchain init or moc-wrapper.
  • One lockfile model: plain commands maintain mops.lock, --locked enforces it in CI. --lock <check|update|ignore> is gone.
  • Integrity is verified as packages download, and mops verify audits what is already on disk. GitHub dependencies are now covered by the lockfile too.
  • Installs are parallel and measurably faster. A cold install of 8 root packages plus transitives went from 19.7 s to 13.2 s, and roughly 1.5 s of fixed cost came off every run.

:wrench: Mops owns the toolchain

[toolchain]
moc = "1.12.0"
pocket-ic = "15.0.0"

Every command that compiles requires a moc pin. Replica tests, mops bench and mops build --check-deploy require a pocket-ic pin. Unpinned, they stop with an error naming the exact command to run (mops toolchain use moc <version>). The point is that the same commit builds the same way today and next year.

The one place this bites an existing project is [optimize]. In 2.x, a project with [optimize] and no wasm-opt pin had its mops.toml rewritten by the next build, with the version chosen by a network lookup at build time, so the same commit produced different artifacts either side of a Binaryen release. That is gone: pin Binaryen yourself and commit it. A wasm-opt failure now fails the build instead of quietly shipping an unoptimized module.

:waving_hand: dfx support is removed

Mops neither invokes dfx nor supports projects that build with it. The change most likely to surprise you is mops toolchain init. Its job was exporting DFX_MOC_PATH=moc-wrapper so dfx build compiled with your pinned moc. Both it and the moc-wrapper binary are gone.

  • :warning: Delete export DFX_MOC_PATH=moc-wrapper from your shell config. While it points at the removed binary, dfx build fails outright.
  • mops sources is unchanged, byte for byte. dfx keeps resolving your Mops dependencies through it as a packtool.
  • But dfx build now uses its own bundled compiler while mops check, build and test use your pinned one. A program that passes mops check can build differently, or not at all, under dfx. The same applies silently in GitHub Actions: Mops no longer writes DFX_MOC_PATH into $GITHUB_ENV, so such a workflow stays green while compiling with a different moc.

The supported path is icp, whose Motoko recipe builds by invoking mops build, so your pin propagates. If you stay on dfx, set DFX_MOC_PATH yourself and keep it in step with [toolchain] moc.

Also gone with dfx: the --replica flag on mops test and mops bench (PocketIC is always used), mops watch --deploy and --generate, and the dfx field in [package].

One thing to expect rather than fix: benchmark baselines drift. PocketIC and the dfx replica report different instruction and heap counts, so the first mops bench --compare after upgrading shows a large diff wherever a dfx replica was implicitly in use. That is a change of measuring instrument, not a regression. Re-record with mops bench --save.

:locked_with_key: One lockfile model

Following cargo, --lock <check|update|ignore> is replaced by a single flag:

  • Plain commands are the dev flow. They maintain mops.lock, and a missing, unparseable, legacy-format or inconsistent lockfile is regenerated rather than treated as an error.
  • --locked is the CI flow. It requires an up-to-date lockfile and never writes one, so a CI run cannot mutate it. It is accepted by mops install and every implicitly-installing command (build, check, check-candid, check-stable, test, bench, generate candid), so a pipeline can run mops test --locked with no separate install step.

The CI environment variable no longer switches mops install into check mode. CI opts in explicitly now.

The guidance changed too: commit mops.lock, libraries included. A library’s lock has no effect on consumers, since they resolve their own graph, and it makes the library’s own CI reproducible.

mops.lock is also a trust anchor now. When the lockfile already covers a package being downloaded, its bytes are verified against the hashes recorded there, so a clean clone with a committed lock and a cold cache makes no registry consensus call at all. Previously it made one per package, at roughly 1.2 to 2.5 s each, each blocking the next. This is the same model cargo uses.

:shield: Integrity moved to download time

Files are hashed as they arrive and compared against the registry before a package is committed to the cache, so a corrupted download never reaches your project. That replaces re-hashing all of .mops/ on every install, which cost time proportional to your whole dependency tree on every single command.

The tradeoff, stated plainly: editing a file under .mops/ no longer fails your next install, because installs are not a tamper gate for files already on disk. If you want that audit, it is now a command:

mops verify

It re-hashes every file the lockfile records and checks the lock against mops.toml and the registry.

GitHub dependencies (repo = "...") are now covered by the lockfile: the resolved commit and a content hash are recorded, and a bare #main or a tag is resolved once, pinned, and then fetched by commit. A moved tag or a force-push can no longer silently change what you build.

:high_voltage: Faster

Packages download through a bounded pool instead of one at a time, sharing a request budget derived from your CPU count and file-descriptor limit (--concurrency or MOPS_CONCURRENCY to override). Registry hash lookups are batched into a single call that starts before the downloads. Dependency resolution runs once per command instead of three to five times. mops update is single-pass. Installs also self-heal on transient network failures: a fetch failed, ECONNRESET or EMFILE retries with concurrency halved rather than aborting.

:clipboard: Everything else worth knowing

  • mops set-network and get-network are removed. Use MOPS_NETWORK=local|staging; unset means ic.
  • Vessel/dhall migration is removed. Copy dependencies into mops.toml by hand.
  • Error messages now go to stderr, so mops <cmd> > out.txt no longer swallows failures.
  • Unknown flags before -- are rejected instead of being silently swallowed as arguments. The -- <tool flags> passthrough is unaffected.
  • Exit codes are consistent now. mops outdated exits 1 when updates are available, so it works as a CI gate, and 2 when the check itself failed.
  • The check-stable baseline must be a .most file. A .mo source is rejected, because it described whatever the source said at run time rather than what the canister actually holds.
  • mops watch with no flags runs the safe informative set (errors, warnings, formatting). --test is opt-in.

:compass: Migrating

The changelog opens with a Migrating from 2.x section: everything that requires action, in one list, each item linking to the detail. If you read one thing before upgrading, read that.

The 2.x documentation stays available at docs.mops.one/2.x.

:link: Links

Happy to answer questions in this thread, especially if the dfx removal breaks something in your setup that the migration list does not cover.

https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExcndnNjB4bDI0bm5jcDN3dWFsZTMyb2hqanphbnBkZHZzYjBhMTZzdSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/6bXwBcxB0KPcs/giphy.gif

You’ll rip dfx from my cold dead hands.

(congrats on the release)