Skip to content

docs: add a guide for driving mops from another build system - #711

Open
Kamirus wants to merge 25 commits into
mainfrom
kamil-v3/docs-build-integration
Open

docs: add a guide for driving mops from another build system#711
Kamirus wants to merge 25 commits into
mainfrom
kamil-v3/docs-build-integration

Conversation

@Kamirus

@Kamirus Kamirus commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Every other page in the docs assumes a person at a terminal. A CI pipeline, canister orchestrator or attestation system has different questions, and today has to reverse-engineer the answers from source: which commands write which files, what reaches the network and when, what the exit codes distinguish, and how to pull out the facts a build record needs.

Prompted by the Canic integration, whose questions were almost all answerable from a page that did not exist.

Stacked on #710 — this page documents the build-does-not-write-mops.toml behaviour that PR introduces, so it is based on that branch. Merge #710 first; GitHub retargets this to v3 automatically.

The two warnings worth landing on their own

--locked does not detect on-disk tampering. It checks the lockfile against mops.toml and against the hashes the registry publishes — not the bytes in .mops/. 3.0.0 moved integrity verification to download time, so editing a file under .mops/ afterwards does not fail a later build. mops verify is the gate that catches it. That distinction only existed in the changelog.

There is no offline mode. --locked reaches the registry on every run, because validating the lock means comparing against currently-published file hashes — a warm cache removes the downloads, not the query. Anyone designing a no-network pipeline needs to know that up front rather than discovering it when the lock check fails.

Contents

  • The authoritative pipeline (install --lockedverifycheck --lockedbuild --locked) and why order matters
  • What each command writes, and the split between project-local .mops/ and the XDG_CACHE_HOME-relocatable global cache
  • What touches the network at each step, per command
  • Exit codes, in particular moc's 1 (source rejected, user error) versus 2 (compiler crashed, toolchain bug) being passed through unchanged
  • The introspection surface: mops sources, mops moc-args, mops toolchain bin, mops --version
  • Why mops sources deliberately has no --locked, and what a pipeline that only calls it gives up
  • Output layout, that nothing is gzipped, and that builds are reproducible
  • The -- <moc flags> passthrough, the argument-assembly order that makes an injected --package win, and the caveat that check and build resolve arguments independently

What is unchanged

No code, and no behavior — this documents what v3 already does. The Docusaurus build passes, which is what validates the cross-links.

Facts that read as gaps rather than documentation (an --offline flag, a machine-readable build record) are deliberately absent rather than described as future work.

🤖 Generated with Claude Code

Kamirus and others added 21 commits August 5, 2026 16:31
…ports (#674)

Part of v3 (NEXT-MAJOR.md "Toolchain & runtime" / "Cleanup that affects
users"). Targets the `v3` integration branch.

Node.js 18 is no longer supported: `cli/package.json` engines is now
`>=20.0.0`, so `npm i -g ic-mops@3` on Node 18 fails with an engines
error (part of [#288](#288)).
CI already tests 22/24/26 and the docs quick-start already states `>=
20.0.0`; the CLI README now says so too. No Node-18 compat workarounds
existed in the code (`--experimental-vm-modules` in the Jest script is
an ESM requirement, still needed on 20+).

The rest was dead weight from the pre-toolchain era:

- **mocv detection dropped.** `mops toolchain init` no longer refuses to
run when `mocv` is on PATH (and no longer strips mocv-era `DFX_MOC_PATH`
lines from shell configs), and `mops docs` no longer resolves `mo-doc`
from a mocv-managed `DFX_MOC_PATH` — it always goes through the
toolchain (pinned `[toolchain] moc`, dfx fallback). Verified in a temp
project with `moc = "0.16.1"` pinned: `mops toolchain bin moc` and `mops
docs generate` both work.
- **`// compatibility with older versions` re-exports removed** from
`cli/mops.ts` (`getNetwork`, `mainActor`, `storageActor`,
`getHighestVersion`). One in-repo user was still on the legacy path:
`cli/cache.ts` imported `getNetwork` from `./mops.js`; it now imports
from `./api/network.js`. Nothing else in the repo (including test
fixtures and `.agents/`) used them.

`npm run check` clean, full CLI Jest green (24 suites, 193 tests).

## What is unchanged

`cli/package.json` is otherwise untouched — dep bumps and `files`
changes belong to sibling v3 PRs. `blog/` and `docs/` keep their own
Docusaurus `engines`; those are site build requirements, not the CLI's.
Merged `v3` in after
[#674](#674) landed; the only
conflict was `cli/CHANGELOG.md`, where both PRs opened a `## 3.0.0
(unreleased)` section — both entry sets now live under the single
heading, `## Next` left empty on top. No source-file overlap with #674.

Part of the v3 breaking scope (NEXT-MAJOR.md "Defaults & UX"): mistyped
flags now fail loudly instead of being silently swallowed, `mops watch`
stops deploying and running tests unless asked, `mops test` shows
`Debug.print` output by default, `--versions` output matches `toolchain
info`, and the pinned-lintoko lint step in `mops check` gets a one-run
opt-out.

## Unknown flags before `--` are rejected

All seven `allowUnknownOption(true)` workarounds in `cli/cli.ts` are
gone — none had to stay. They predate Commander 13; since the upgrade,
everything after `--` is parsed as operands, so strict option parsing
and the `-- <tool flags>` passthrough coexist fine (verified empirically
against the pinned `commander` 13.1.0 for every passthrough command
shape).

Before:

```
$ mops check --nope
No canisters defined in mops.toml.
Either pass files: mops check <files...>
...
```

After:

```
$ mops check --nope
error: unknown option '--nope'
$ mops check Warning.mo -- -Werror
Warning.mo:3.9-3.15: warning [M0194], unused identifier: `unused`
✗ Check failed for file Warning.mo (exit code: 1)
```

One real bug surfaced: `mops lint -- --severity warning` (advertised in
`--help` and docs) has been broken since the Commander 13 upgrade — with
`lint [filter]` declaring arity 1, the passthrough operands tripped
Commander 13's new excess-arguments error, `allowUnknownOption`
notwithstanding. `lint` now takes `[filter...]` like `test` does (the
variadic exists only to absorb the passthrough operands; a single filter
is still the interface), which fixes the passthrough under strict
parsing. Covered by new tests.

## `mops watch`: conservative defaults, formatting on

Before (no flags):

```
Errors: 0
Warnings: 0
Tests: 2
Generate: 0
Deploy: 0
```

After (no flags):

```
Errors: 0
Warnings: 0
Format: 0
```

Final matrix:

| Task | Old default (no flags) | New default (no flags) |
|---|---|---|
| `--error` | on (always) | on (always) |
| `--warning` | on | on |
| `--format` | off | **on** |
| `--test` | on | **opt-in** |
| `--generate` | on | **opt-in** |
| `--deploy` | on | **opt-in** |

Rationale: the default set is everything informative and safe (checks +
formatting, satisfying the `--format`-by-default ask in
[#288](#288)); anything heavy
or side-effectful beyond the working tree (running tests, generating
declarations, deploying canisters) never runs unless named. Passing any
flag still means "only the selected tasks" (plus the always-on error
check), unchanged from v2.

That last part matters for the migration recipe: because any flag
narrows the set, `-tgd` alone drops the warning check too. `mops watch
-wtgd` reproduces the old default exactly, and `-wtgdf` is the old
default plus the new formatting — verified:

```
$ mops watch -wtgd          $ mops watch -wtgdf
Errors: 0                   Errors: 0
Warnings: 0                 Warnings: 0
Tests: 2                    Format: 0
Generate: 0                 Tests: 2
Deploy: 0                   Generate: 0
                            Deploy: 0
```

## `mops info <pkg> --versions`: newest-first

Matches `mops toolchain info --versions`, per the v3 note that was
already sitting in `cli/commands/info.ts`.

Before:

```
$ mops info core --versions | head -5
0.5.0
0.6.0
1.0.0
2.0.0
2.1.0
```

After:

```
$ mops info core --versions | head -5
2.6.1
2.6.0
2.5.0
2.4.0
2.3.1
```

## `mops test`: verbose reporter by default

The old default switched to the `files` reporter as soon as a second
test file appeared, hiding `Debug.print` output
([#288](#288)). Now `verbose`
regardless of file count; the auto-pick heuristic is removed rather than
kept, since a default that changes with file count is itself the
complaint. Internal callers (`publish`, `watch`) pass reporter instances
and are unaffected.

After (two files — previously this showed only two filenames):

```
$ mops test
Running test/b.test.mo
 stdout hello from b
 PASS
--------------------------------------------------
Running test/a.test.mo
 stdout hello from a
 PASS
```

## `mops check --no-lint`

Since 2.6, `mops check` runs `mops lint` whenever `lintoko` is pinned in
`[toolchain]` — that stays the default and is not gated on anything. New
`--no-lint` skips the lint step for a single run. Projects without a
`lintoko` pin are unaffected.

## Migration notes

- Scripts passing flags mops doesn't recognize now fail with `error:
unknown option`; drop the flag or move it after `--` if it was meant for
moc/lintoko.
- Scripts using `mops info <pkg> --versions | tail -1` to get the latest
version should use `head -1`.
- `mops watch` users relying on implicit tests/declarations/deploys must
name them: `mops watch -wtgd` for the old default, `-wtgdf` to add
formatting. `-tgd` alone also drops the warning check.
- Multi-file `mops test` output changes shape; pass `--reporter files`
for the old output.

## What is unchanged

- All 73 CLI snapshots pass unmodified — none of the flipped defaults
were snapshot-covered (the one `mops test` integration test pins
`--reporter verbose` explicitly), so no snapshot updates were needed.
- The reporter flip is guarded by a new two-file fixture
(`cli/tests/test-reporter/`) asserting `Debug.print` output reaches
stdout by default and disappears under `--reporter files`. Verified it
fails against the pre-v3 behavior. Targeted assertions rather than a
snapshot: `mops test` output carries a wall-clock duration, which is why
no snapshot covers it today. The `watch` default matrix (long-running
chokidar loop) and `--versions` order (live registry call) stay
uncovered as at base; both were verified manually, output above.
- `mops docs coverage --reporter` keeps its `files` default (a coverage
report, not a test run — different tradeoff).
- Docs (`mops-check`, `mops-lint`, `mops-test`, `mops-watch`,
`mops-info`), `--help` texts, `.agents/skills/mops-cli/SKILL.md` and the
changelog (under `## 3.0.0 (unreleased)`, below the `## Next` heading
per the release workflow) are updated in step.
…or conflicts (#679)

Two correctness fixes in dependency resolution. Which version gets
picked is unchanged.

## Cross-major conflicts are now reported

`mops install` used to resolve a graph where one package asks for `1.x`
and another for `2.x`, pick one, and say nothing. The "Conflicting
versions" report existed, but it was gated behind a `conflicts` option
that defaulted to `"ignore"` at every call site except `mops sources` —
and by the time `mops install` reached its own explicit `conflicts:
"warning"` check, `installAll` had already written the lockfile, so that
call was served from the lock and never walked the graph. In practice
you only saw the report by running `mops sources` by hand on a project
with no lockfile.

On a project where the root pins `test = "2.1.2"` and a local package
pins `test = "1.2.0"`:

Before

```
$ mops install
Installing test@2.1.2 (cache)
Installing core@2.0.0 (cache)
Local dependency legacy = "…/p4-cross-major/legacy"
Installing test@1.2.0 (cache)
Installing base@0.10.2 (cache)
Checking integrity...
mops.lock created.
  Applications: commit this file.
  Libraries: add mops.lock to .gitignore.
Packages installed
```

After

```
$ mops install
Installing test@2.1.2 (cache)
Installing core@2.0.0 (cache)
Local dependency legacy = "…/p4-cross-major/legacy"
Installing test@1.2.0 (cache)
Installing base@0.10.2 (cache)
Checking integrity...
Warning! Conflicting major versions of dependency "test"
  test 1.2.0 is a dependency of legacy@1.0.0
  test 2.1.2 is a dependency of p4@1.0.0
  Resolved to test 2.1.2 — dependents on another major compile against an API they did not ask for.
  If you want a different version, pin it in your root mops.toml — a root dependency always wins.
mops.lock created.
  Applications: commit this file.
  Libraries: add mops.lock to .gitignore.
Packages installed
```

It stays a warning and resolution still succeeds. The remedy is
deliberately phrased as a conditional: in the example above the root
*already* pins `test`, and with three dependents across three majors no
single pin clears the conflict, so the message states what won and
leaves pinning to the user rather than naming a version that would often
be a downgrade.

The report also handles the case where the root wins with a `path` or
`repo` dependency. Those resolve to a path or a repo rather than a
version, so there was no version to print, and the resolution line and
the remedy were both skipped — leaving a bare conflict header on a graph
the root had deliberately resolved. It now reads `Resolved to the root
override test = "vendor/test"`.

Same-major skew (`2.0.0` vs `2.1.2`) stays silent, which is what
max-wins is for.

## `--conflicts` now governs the whole command

Conflict reporting became a per-command policy (`setConflictPolicy`)
rather than a per-`resolvePackages` option. One command resolves three
to five times — local cache sync, lockfile write, integrity check, then
the resolve that produces the output — so a per-call option could only
ever control the last of them. That is why the first version of this PR
had `mops sources --conflicts error` exit 1 under a report labelled
`Warning!`, and why `--conflicts ignore` still printed: the earlier
passes never saw the flag.

`--conflicts ignore` is a real opt-out again. `mops sources` is invoked
by dfx's packtool on every build, so an unsilenceable report means a
project that has knowingly accepted a cross-major override gets noise on
every `dfx build` and `dfx deploy` forever with no escape. `--conflicts
ignore` typed into `dfx.json` is an explicit acknowledgement, not hidden
state, so honoring it keeps the report loud by default without making it
inescapable. `warning` is the default everywhere, including
`sourcesArgs`, which previously defaulted to `"ignore"`. `error` still
exits 1, and prints its own `Error!` line after the report instead of
relabelling one an earlier pass already printed. Commands other than
`mops sources` have no opt-out.

The report is deduped on the conflict itself — the dependency plus its
sorted set of dependents and versions — rather than on the dependency
name. That collapses the passes within one command and is what makes the
`hasConflicts` decision consistent across them, while still letting a
genuinely different set of dependents through.

`mops watch` cannot surface the report yet, and this PR does not fix
that. `ErrorChecker.run()` resolves (report to stderr) and then calls
`onProgress()`, and watch's `print()` opens with `console.clear()` plus
a `\x1Bc` terminal reset — so the report is wiped milliseconds after it
is printed, and every later cycle has identical conflict content.
Threading conflicts into watch's render loop is a separate subsystem
that #676 just reworked, so it is filed as a follow-up rather than
bolted on here.

The report goes to stderr, which it already did, so `mops sources`
stdout stays parseable by dfx.

Only registry dependencies take part in a conflict: detection filters to
them, because a `repo` or `path` dependency has no comparable major
version. The docs now say so — they previously promised coverage of "any
dependency". That filter is pre-existing behavior and is left alone
here.

This found a real conflict on its first run: the mops repo's own root
`mops.toml` pins `fuzz = "1.0.0"` in dev-dependencies while
`memory-region@0.1.1` asks for `fuzz 0.1.0`, so `memory-region` has been
compiling against `fuzz` 1.x. Not fixed here, but it is no longer
invisible.

## Why the comparator was wrong

The old comparator split on `.`, `parseInt`ed the parts, and kept three
of them. `compareVersions("1.2.0-rc.1", "1.2.0")` returned `0`:
`parseInt("0-rc")` is `0`, so both sides became `[1, 2, 0]` and the
`rc.1` was invisible. The resolver only replaces the incumbent on a
strict `-1`, so whichever version was walked first won. With two
packages depending on the same GitHub repo, one at `#v1.2.0-rc.1` and
one at `#v1.2.0`, mops built against the release candidate:

```
$ mops sources --no-install    # before
--package gitdep .mops/_github/gitdep#v1.2.0-rc.1/src

$ mops sources --no-install    # after
--package gitdep .mops/_github/gitdep#v1.2.0/src
```

Other pairs it got wrong, all verified against the old code:

| a | b | old | correct |
|---|---|---|---|
| `1.2.0-rc.1` | `1.2.0` | `0` | `-1` |
| `1.2.0-rc.2` | `1.2.0-rc.10` | `0` | `-1` |
| `1.2.0-alpha` | `1.2.0-beta` | `0` | `-1` |
| `0.16` | `0.16.1` | `0` | `-1` |
| `1.2` | `1.2.9` | `0` | `-1` |

Two-part versions failed because the comparator reached `ap[2] - bp[2]`
with `ap[2]` undefined, and `undefined - 1` is `NaN`, which is falsy —
so it fell through to `return 0` and the patch comparison never
happened. Separately, `compareGitVersions` matched a tag with
`/v(\d{1,2}\.\d{1,2}\.\d{1,2})(-.*)?$/` and then threw the capture group
away in favour of `branch.substring(1)`, so a tag like `release-v1.2.0`
was compared as `elease-v1.2.0` → `0.2.0` and lost to any `1.x` tag. It
now uses the captured version.

## Leading zeros, and why parsing is `loose`

The registry's validator (`backend/main/utils/semver.mo`) checks dots,
digit-run length and charset. It does **not** reject leading zeros, so
`01.2.3` is publishable, and `Semver.major("01.2.3")` is `1` there
because `textToNat("01")` is `1`. Strict `semver` rejects the whole
string — both `parse` and `coerce` return null — so it would have fallen
through to the `0.0.0` fallback. That is a silent downgrade, not an edge
case:

```
# two transitive deps on the same git dep, one tagged v01.2.0, one v1.0.0
$ mops sources --no-install    # strict parsing
--package gitdep .mops/_github/gitdep#v1.0.0/src

$ mops sources --no-install    # loose parsing (and matching the old comparator)
--package gitdep .mops/_github/gitdep#v01.2.0/src
```

Secondary effect: `majorVersion("01.2.3")` would have been `0`, so a
genuine 1.x/0.x conflict would have gone undetected by the very warning
this PR adds. Both `parse` and `coerce` now pass `loose: true`. A
20-pair differential against the old comparator confirms this changes
exactly the three leading-zero shapes (`01.2.3`, `1.02.3`, `0.16.01`)
and nothing else — strict differed from the old comparator on 9 of 20
pairs, loose on 6, and the 3 removed are precisely the leading-zero
rows.

## What flows through the comparator

Registry versions (from the root `mops.toml` and from published
packages' `mops.toml`) and GitHub refs. Alias deps are aliases on the
*name* (`"core@1" = "1.0.0"`), so the version side is always a full
version; `getDepPinnedVersion`'s two-part `0.16` never reaches this
comparator.

Registry versions are validated on publish as three numeric parts, each
at most two digits, no prerelease — leading zeros allowed. Everything
else — prerelease git tags, prefixed tags, hand-written two-part
versions, non-version refs like `main` — goes through `semver.coerce(…,
{ loose: true, includePrerelease: true })`, and anything with no version
in it at all sorts as `0.0.0`, which is what `parseInt(x) || 0` did, so
`main` still loses to every real version. The comparator is total and
never throws; there is a test asserting that over `""`, `"..."`,
`"1.2.3.4.5"` and a 400-digit string.

`parseVersion` must not be pointed at a raw git ref — coercion picks up
the first number-like run anywhere in the string, so a branch named
`release-2024` would coerce to `2024.0.0` and outrank every real
version. The resolver extracts the version from the ref first
(`gitRefVersion`), and the docstring says so.

CLI vs backend agreement: on the set the registry can actually contain,
the two now agree, *including* leading zeros —
`cli/tests/compare-versions.test.ts` pins that with a case table that
now covers `01.2.3` vs `1.0.0` and `0.16.01` vs `0.16.1`, the shapes the
earlier version of this PR got wrong while claiming agreement. One
disagreement remains outside that set: the backend ignores prerelease
suffixes entirely, so it considers `1.2.3-rc.1` equal to `1.2.3`, while
the CLI orders it below. The backend also refuses to publish such a
version, so the CLI is strictly more correct on inputs the registry
cannot hold. `mops update` and `mops outdated` already used the `semver`
package (`cli/commands/available-updates.ts`), so the resolver now
shares it.

## What is unchanged

Resolution semantics are untouched. A bare `1.2.3` is still exact,
conflicts still resolve max-wins with the root dependency always
winning, and there is no caret, no constraint intersection and no `=`
syntax — those stay deferred to the major gated on moc `--override`.

Proof: `mops sources` stdout, byte for byte, before and after, across
nine projects covering registry deps, alias deps (`"core@1" = "1.0.0"`),
a local `path` dep with its own transitive registry deps, GitHub `repo`
deps (versioned tag, a `moc-0.9.1` tag that carries no comparable
version, and no ref at all), a genuine cross-major diamond, a same-major
diamond, a diamond with two non-root dependents on the same registry
dep, and leading-zero git tags.

| project | stdout md5 before | stdout md5 after |
|---|---|---|
| registry + alias deps | `f8d021bd0e2e2ce0ea29ac2ad64a7213` |
`f8d021bd0e2e2ce0ea29ac2ad64a7213` |
| github repo deps | `2ebfbb62692ab5a8427d3bd29457322b` |
`2ebfbb62692ab5a8427d3bd29457322b` |
| local path dep | `9572240eb40d13c332808e269cfe1513` |
`9572240eb40d13c332808e269cfe1513` |
| cross-major diamond | `a9c6bb030e3379294698fa4267c6cb7b` |
`a9c6bb030e3379294698fa4267c6cb7b` |
| same-major diamond | `88f294e187173442f192d58e1e96ff93` |
`88f294e187173442f192d58e1e96ff93` |
| registry diamond | `f49e353bb3838eb905a67cc6718941c1` |
`f49e353bb3838eb905a67cc6718941c1` |
| leading-zero git tags | `#v01.2.0` | `#v01.2.0` |
| git prefixed tag | `12737a431362a3f541d6830c95936679` |
`ee0c1cd25ac05a98f50d4a101421a019` |
| git prerelease tag | `ca71b4552890d79e815ccd49c6e56233` |
`b47f5575220d6f3cddbf602b602e2bf8` |

The only two that move are the projects built specifically to trigger
the mis-ordering, and in both the new output is the correct answer. The
cross-major project's stdout is identical — only its stderr gains the
report.

The suite corroborates this: 25 suites / 199 tests / 73 snapshots
measured on `v3` at the merge base, 27 / 217 / 74 on this branch —
exactly the two new suites, their 18 tests and one new snapshot, with
all 73 pre-existing snapshots unchanged. `npm run check` and `npm run
lint` are clean.

One existing fixture did move, and it is worth calling out.
`cli/tests/check-candid/` had no `mops.toml`, so `getRootDir()` walked
up and resolved the mops repo's own dependency graph — the one with the
`fuzz` conflict — and the new report landed in nine committed snapshots.
Rather than baking this repo's root `mops.toml` into `check-candid`'s
snapshots, the fixture now has its own minimal config. `check-candid`
needs a config file to exist but reads no dependencies (the check is
pure Wasm), so its snapshots are byte-identical to what was committed
and it no longer depends on the repo root.

## Known limitation

A valid `mops.lock` short-circuits resolution, so a fresh clone of a
project with a committed lockfile installs from the lock, never re-walks
the graph, and does not see the report. The report fires on every
resolution that actually happens — no lockfile, or one invalidated by a
`mops.toml` change — which covers the run that produced the lockfile in
the first place, but not later clones of it. Closing that gap means
resolving without the lock, which is not safe today: installing from a
lockfile deliberately skips the versions that *lost* the conflict, so
their `mops.toml` is not in the cache and the resolver would crash
reading it. That belongs with the `--locked` and lockfile work in
`NEXT-MAJOR.md`.

## Deliberately out of scope

Collapsing the `conflicts` parameter to a boolean, now that `ignore`
stays meaningful and there are three distinct behaviours. The
`<unknown>` fallback for a dependent with no `[package]` section, which
predates this PR. Surfacing the report in `mops watch`, and a
project-level `mops.toml` opt-out for acknowledged conflicts
(`--conflicts` exists only on `sources`, so
`build`/`check`/`test`/`lint` report unconditionally) — both filed as
follow-ups. A `cli/tests/mops.toml` fence for the whole fixture tree,
which would change the resolution root for other config-less fixtures
and so needs its own snapshot run.

`updateVersion` in `cli/commands/bump.ts` still hand-rolls version
arithmetic with `split(".")` and `parseInt`. It increments rather than
compares, and only ever sees the project's own already-validated
`[package] version`, so `semver.inc` would be churn without a bug behind
it. `cli/commands/toolchain/release-tags.ts` already sorts with
`semver`. The remaining `split(".")` hits in `cli/mops.ts` and
`cli/commands/available-updates.ts` read a single part or count
segments; they do not order anything.
Part of v3 (NEXT-MAJOR.md "Drop vessel / dhall"). Targets the `v3`
integration branch.

Vessel/dhall support is gone in mops v3, after being deprecated with a
warning since 2.14
([#296](#296)):

- `mops init` no longer reads `vessel.dhall` and no longer migrates its
dependencies into the new `mops.toml`.
- Transitive dependencies of GitHub deps declared via `vessel.dhall` /
`package-set.dhall` are no longer resolved or installed.
- `.vessel` directories are no longer excluded from `mops test` / `mops
watch` file scans.
- `readVesselConfig`, `cli/vessel.ts` and the `dhall-to-json-cli`
dependency are removed.

`installFromGithub` is not vessel — it serves ordinary `repo = "..."`
GitHub deps and only lived in `vessel.ts` for historical reasons. The
first commit moves it (with its `downloadFromGithub` helper) to
`cli/commands/install/install-from-github.ts` behavior-intact; the
second commit drops vessel around it. `cli/tests` had no coverage for
GitHub deps at all, so `install-github-dep.test.ts` now installs a
commit-pinned `repo` dep and asserts the extracted tree, the lockfile
and the cache hit on a second run — the regression net for this move.
Also verified by hand: `mops add
https://github.com/ZenVoich/test#master` in a fresh project downloads,
extracts and pins the dep.

`npm run check` clean, `npm run lint` clean, full CLI Jest green (26
suites, 200 tests, 73 snapshots — the current `v3` baseline plus the new
GitHub-dep test).

Regenerating `cli/bun.lock` also picks up drift that predates this
branch: `bun.lock` on `v3` still lists `decomp-tarxz` and lacks
`xz-decompress` / `tar@7.5.22`, because
[#667](#667) updated
`package.json` and `package-lock.json` but not `bun.lock`. The
regenerated lockfile now matches `package.json` again.

## Migration

If you still have a `vessel.dhall`, copy its dependencies into
`mops.toml` yourself (registry packages as `name = "version"`, git
packages as `name = "https://github.com/org/repo#ref"`), then delete
`vessel.dhall` / `package-set.dhall`. If a GitHub dep relied on vessel
files for its own dependencies, add those to your `mops.toml` directly.

## What is unchanged

GitHub deps still extract archives with `decompress`; replacing it is
separate, non-breaking 2.x work tracked in `TODO.md` (toolchain archives
already moved off it in
[#667](#667)).
)

Stacked on #675, which introduced `cli/tests/install-github-dep.test.ts`
covering install and the cache hit. This extends that suite instead of
adding a second file — I had written a parallel one before #675 grew its
own, and two overlapping github-dep test files is worse than one.
Retarget to `v3` once #675 merges.

Three gaps remain after #675:

- **`sources` resolution.** Landing on disk is not enough; the dep has
to reach moc as a `--package` flag. Asserts `--package test
.mops/_github/test#master@<sha>/src`, plus that the lockfile records the
repo url verbatim (which is what pins the commit).
- **`mops add <url>` pinning.** Resolving a branch to a commit and
writing it into both `mops.toml` and the lockfile is the reproducibility
guarantee for github deps, and nothing checked it. This is the one case
that deliberately resolves a branch rather than using a pinned sha,
since that resolution is the behavior under test.
- **Failure path.** A nonexistent repo must fail without recording a dep
that can never install.

Fixtures for the mutating cases are copied per-test via the existing
`useTempFixtures` helper, so nothing touches a source-controlled
fixture.

This path is also the last remaining `decompress` call site — #667 moved
toolchain extraction to `tar` plus a standalone xz decompressor but not
this one, and `decompress` has two critical advisories with no fixed
version. Whoever swaps the extractor needs this coverage.

No CHANGELOG entry: test-only.
…ass, shell init, exit code) (#677)

Four sources of invisible shared state are removed, per the
"Hidden-state cleanup" section of NEXT-MAJOR.md, whose four items are
now marked done. All four are breaking; changelog entries with migration
notes are under `## 3.0.0 (unreleased)`.
[#674](#674) and
[#676](#676) are merged in:
#674's mocv-guard removal met the `toolchain init` rewrite here, and
#676's `allowUnknownOption` purge met the deletion of the network
command registrations in `cli/cli.ts` — both sides kept in each case (no
`allowUnknownOption` call and no fragment of the removed commands
survives).

The network item is a **deletion**: `mops set-network`/`get-network` and
their storage file are gone, with `MOPS_NETWORK` as the sole mechanism.
An earlier revision of this PR relocated the file to `.mops/network`
plus an XDG global instead — see the reasoning below for why relocation
was dropped.

## `mops set-network` / `get-network` are removed, not relocated

The selected network was stored in a `network.txt` inside the
npm-installed package, so switching one project to `local` silently
switched every project sharing that `ic-mops` install, and any `npm i -g
ic-mops` reset the choice.

Before:

```
~/project-a $ mops set-network local
Selected 'local' network
~/project-a $ cd ~/project-b && mops get-network
local        # project-b now talks to a local replica too
~/project-b $ npm i -g ic-mops && mops get-network
ic           # upgrade wiped the setting for every project
```

After:

```
$ mops set-network local
error: unknown command 'set-network'
$ MOPS_NETWORK=local mops install     # per command
$ export MOPS_NETWORK=local           # or per shell
```

**Why deleted rather than moved to a writable location.** `MOPS_NETWORK`
was added in [#437](#437)
(2.5.1) precisely because writing into the install directory "fails in
read-only environments like CI pipelines and Docker containers" — i.e.
the env var already exists as the workaround for this storage location
being broken, and it won: it is what the docs recommend, what this
repo's own dev loop and CI use, and no caller of `set-network` exists
anywhere in the repo. Keeping persistence would mean maintaining a
second mechanism for the same setting, and the first draft of this PR
showed the cost: durable config under `.mops/` needed a
`PROJECT_STATE_FILES` special case so `mops cache clean` would not wipe
it. Config that has to be shielded from a cache wipe is in the wrong
place.

Deleted: both commands and their `sn`/`gn` aliases, the startup block
that read the file into `globalThis.MOPS_NETWORK`,
`getNetworkFile()`/`setNetwork()` and the project/global/legacy readers,
the `network.txt` entry in `cli/.gitignore`, and the
`docs/.../07-mops-set-network.md` page drafted earlier in this PR.
`DEVELOPMENT.md`'s local-registry setup now says `export
MOPS_NETWORK=local` instead of `mops-local set-network local`.
`cli/cache.ts` is byte-identical to `v3` again.

`cli/api/network.ts` is unchanged: `MOPS_NETWORK` env →
`globalThis.MOPS_NETWORK` → `"ic"`. The `globalThis` branch stays
because it is **not** dead —
`frontend/components/package/Package.svelte` sets `window.MOPS_NETWORK`
before calling into the mops API in the browser, where there is no
`process.env` (`frontend/types.d.ts` documents both halves of that
contract). Its ambient `declare global` moved from `cli/cli.ts` into
`cli/api/network.ts`, next to the only code that reads it.

## `mops build`/`check`/`check-stable`/`check-candid`/`test`/`bench`
respect `mops.lock`

These commands forced `installAll({ lock: "ignore" })`, so a stale or
tampered lock that would fail `mops install` was silently accepted on
every build. They now run the same default lock flow as `mops install`:
install from a valid lock, refresh a stale one, and fail on a tampered
one:

```
$ echo '// tampered' >> .mops/core@1.0.0/src/Array.mo
$ mops build foo
Integrity check failed
Mismatched hash for core@1.0.0/src/Array.mo
...
Run `mops install --lock update` to regenerate it.
$ echo $?
1
```

Three deliberate details:

- They pass `defaultLock: "update"` — the same escape the
dependency-mutating commands (`add`/`remove`/`update`/`sync`) already
use — so the deprecated `CI` auto-`--lock check` path never triggers for
commands that have no `--lock` flag to satisfy its warning. This also
matches the v3 end state (plain commands maintain the lock; `--locked`
is the CI flow).
- Lock creation by these commands is silent (cargo behavior: `cargo
build` maintains `Cargo.lock` without commentary); `mops install` still
prints the `mops.lock created` notice.
- Dep-less projects no longer make a registry round-trip to write an
empty-hash lock (`getFileHashesFromRegistry` short-circuits on zero
packages), so builds in projects without registry deps keep working
offline.

One offline case does change: a project with registry deps, a warm
`.mops/` and **no** committed lock now contacts the registry on its
first `build`/`check` to fetch the file hashes it needs to write the
lock. Once a valid lock exists, no request is made (`updateLockFile`
returns early and `checkLockFile` verifies from disk). Applications are
already told to commit `mops.lock`, so the pre-warmed-image flow stays
offline.

Scope notes:

- `mops generate candid` was a seventh identical `lock: "ignore"` site
(added after NEXT-MAJOR.md was written); it is included for consistency
with `build`.
- `mops sources` keeps `lock: "ignore"` on purpose: its stdout is
machine-parsed by the dfx packtool on every dfx build, so it must not
write the lock or print integrity output. Now marked with a comment.
- `--locked` and the removal of `--lock` are separate v3 PRs and are not
touched here.

## `mops toolchain init` is opt-in per shell

It used to write every detected shell init file (`.bashrc`, `.zshrc`,
`.bash_profile`, `.zprofile`) plus `$GITHUB_ENV`. Now it updates only
the current shell's file (detected from `$SHELL`; for bash, `~/.bashrc`
with a `~/.bash_profile` fallback when only that exists), and `--shell
<bash|zsh>` targets a specific one:

```
$ mops toolchain init
Success!
Updated /Users/kamil/.zshrc
Restart terminal to apply changes
$ mops toolchain init --shell bash
Success!
Updated /Users/kamil/.bashrc
Restart terminal to apply changes
```

Unchanged on purpose: `$GITHUB_ENV` is still written in GitHub Actions
(that is the correct propagation mechanism there), the
`checkToolchainInited()` CI auto-init stays, and `mops toolchain reset`
still cleans **all** known files so it fully undoes inits made by older
versions. When `$SHELL` is unset or unsupported, init falls back to the
first existing known init file (keeps CI images without `$SHELL`
working) and only errors — with a `--shell` hint — when there is nothing
to write. It will create the target rc file if the shell has none, which
is what makes init work in a fresh container; previously init refused
outright when no rc file existed.

## Replica bind-failure exit code

**Reviewer decision point:** the dfx replica bind failure in
`cli/commands/replica.ts` exited with code `11`; it now exits `1`.
Pre-agreed recommendation on the grounds that no documented workflow
scripts against `11` and one failure code is a simpler contract —
flagging it here in case anyone knows of a consumer of the old code.

## Tests

- `build.test.ts` gains a `lock policy` block: `mops build` creates
`mops.lock` without printing the install-only `mops.lock created`
notice, and fails with the integrity error after a `.mops/` file is
edited.
- `cli/tests/check-candid/` gained its own `mops.toml`: the fixture had
none, so `mops check-candid` walked up and resolved the **repo root**
project — harmless while the command ignored locks, but now it would
rewrite the repo's own `mops.lock` from inside the test suite.
- The `network.test.ts` added for the relocated file is deleted along
with the feature; there is nothing left to test beyond `getNetwork()`,
which is unchanged. Verified by hand that `MOPS_NETWORK=local` still
reaches the local endpoint (`mops cache show` → `.../mops/local`, `mops
install` dialing `127.0.0.1:4943`), that
`set-network`/`get-network`/`sn`/`gn` all exit 1 as unknown commands,
and that `mops cache clean` removes the whole `.mops` tree as it did
before this PR.
- `cli.test.ts` guards the removal: `set-network`, `get-network` and the
`sn`/`gn` aliases must all fail as unknown commands, and `MOPS_NETWORK`
must still select the network. `--shell` and the exit-code change are
not covered automatically — both would need a test that writes to
`$HOME`, which is not worth the blast radius; verified by hand instead
(per-shell transcript above).
- Full suite on the merged tree: 25 suites / 206 tests / 73 snapshots
pass with zero snapshot updates — the lock-flow change is
output-invisible for valid projects. (This branch alone is 24/195; the
rest arrives with
[#676](#676).)
…tall, download-time verification (#681)

CI pipelines get a real frozen-lockfile flag, and everyone else stops
paying for a check that was re-reading the whole dependency tree on
every command.

`mops` had three lockfile modes behind `--lock <check|update|ignore>`,
plus a fourth that appeared only when the `CI` environment variable
happened to be set. This replaces all of it with the model cargo, pnpm
and yarn converged on: two modes, one flag. Plain commands are the dev
flow and always keep the lock correct. `--locked` is the CI flow — it
requires an up-to-date lock and never writes one.

Integrity moves with it. Verification now happens on the bytes as they
are downloaded, so a corrupted or tampered download can never enter the
cache, and installs no longer re-hash `.mops/`. The on-disk audit
becomes an explicit command, `mops verify`.

Closes #516. Closes #517.

## Before / After

**A missing lock in CI.** Before, `mops test` would silently resolve,
download and write a lockfile nobody reviewed. After:

```console
$ mops test --locked
Error: mops.lock is missing, but --locked was passed.
Run `mops install` to generate it, then commit mops.lock.
```

It fails before downloading anything.

**Someone edited `mops.toml` and forgot to commit the lock.**

```console
$ mops install --locked
Error: mops.toml has changed since mops.lock was generated, but --locked was passed.
  Locked dependencies hash: 90a342a4a0b150765297bd0d09221c6338d3cadc509cda66b944f37fca569f1a
  Actual dependencies hash: 075072533858da89ffa178f5ee95ae566d21fb166294658ca4be9777750029f6
Run `mops install` (without --locked) to update mops.lock, then commit it.
```

Same repo, without `--locked` — the lock is simply brought up to date:

```console
$ mops install
Packages installed
```

**A hand-edited lock that no longer matches the manifest.**

```console
$ mops install --locked
Error: mops.lock does not match mops.toml, but --locked was passed.
  dependency core: mops.toml declares 1.0.0, mops.lock has 2.0.0
Run `mops install` (without --locked) to update mops.lock, then commit it.
```

**A lock whose recorded hashes disagree with the registry.** Note the
hint differs here: plain `mops install` does *not* rewrite this lock, so
pointing at it would loop (see below).

```console
$ mops install --locked
Error: mops.lock does not match the registry, but --locked was passed.
  core@1.0.0/LICENSE: locked bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, registry 840e3d57a38a8061f55d04470fbd58b9345326fa04ea10ee42add5c6e3b2aa08
Restore mops.lock from version control, or delete it and run `mops install` to regenerate it.
```

**A corrupt lock — the self-healing path.** This used to require `mops
install --lock update`, and #514 was about that flag not actually
working:

```console
$ mops install --locked
Error: mops.lock could not be parsed, but --locked was passed.
Restore mops.lock from version control, or delete it and run `mops install` to regenerate it.

$ mops install
Packages installed
```

No flag recovers a broken lock any more, because none is needed.

**An up-to-date lock under `--locked` is left alone** — byte-identical
content and unchanged mtime, verified in `cli/tests/locked.test.ts`.

## The guarantee change: install no longer verifies files already on
disk

**Neither `mops install` nor `mops install --locked` gates a modified
`.mops/` tree any more. `mops verify` is the replacement.** Read this if
any pipeline relies on the old behavior.

This is intentional and is the reason the item sits in a major. It is
called out here, in `cli/CHANGELOG.md`, on the `mops.lock` docs page and
on the new `mops verify` page, and it is pinned by a test in both
`cli/tests/locked.test.ts` and `cli/tests/build.test.ts`.

Integrity is now checked once, at download time, against the hashes
published in the registry, before the package is committed to the cache.
The per-install re-hash of `.mops/` is gone. Editing a dependency in
place therefore no longer fails your next command:

```console
$ echo "// oops" >> .mops/core@1.0.0/src/Array.mo
$ mops install
Packages installed
```

`mops verify` is the replacement, and it reports exactly what install
used to:

```console
$ mops verify
Integrity check failed
.mops/core@1.0.0/src/Array.mo does not match mops.lock
  Locked hash: 7cc453a2d20a3dae84c54f2c4a2db6db7b454f6fca0ba23e6440d324c2a68f3f
  Actual hash: 477f5f7cd24bf2a703faf22e42bc0ea3560428bcc84e8bd68ca08982fc88fcdd
  Delete the `.mops/core@1.0.0` directory and run `mops install` to restore it.

$ mops verify
Integrity verified 3 package(s), 130 file(s)
```

Two consequences worth stating plainly:

- Packages already sitting in the global cache from an older CLI were
never download-verified. `mops verify` audits them; `mops cache clean`
forces a verified re-download.
- Download-time verification is genuinely load-bearing, not decorative.
Corrupting a single byte in transit is caught and the package never
reaches the cache or `.mops/`:

```console
$ mops install
Error: integrity check failed for core@1.0.0
  Hash mismatch for core@1.0.0/src/Array.mo
  Expected: 7cc453a2d20a3dae84c54f2c4a2db6db7b454f6fca0ba23e6440d324c2a68f3f
  Actual:   f414fe00390251acc52b7563aef3e2118afd893a9e0e97bb4e1a06f9d9c09a71
```

### One case is deliberately not self-healed

A lockfile whose recorded file *hash values* are wrong — reachable only
by hand-editing or a botched merge — is not repaired by `mops install`,
because detecting it needs `getFileHashesByPackageIds`, which is an
**update** call, not a query:

| | median warm `mops install` |
|---|---|
| valid lock, no hash fetch | 1.6 s |
| lock regenerated, one hash fetch | 2.8 s |

Paying ~1.2 s on every install to catch a hand-edit would cost roughly
nine times the 136 ms this PR saves, and a warm cache fetches no hashes
at download time either, so there is nothing already in hand to compare
against. Since those values are read only by `--locked` and `mops
verify` and never by the build, a wrong hash cannot produce a wrong
build.

What I did instead: `--locked` and `mops verify` report it with the hint
that actually recovers (restore from version control, or delete and
reinstall) rather than "run `mops install`", which would have looped
forever. Covered by a test that asserts the working hint *and* walks the
recovery.

Two related inconsistencies that *are* detectable offline now self-heal,
one of which was a real correctness hole: a `deps` entry disagreeing
with `mops.toml` used to be installed as-is, silently giving you the
wrong version. Plain `mops install` now re-resolves instead.

## Migration

| Old invocation | New invocation |
|---|---|
| `mops install --lock check` | `mops install --locked` |
| `mops install --lock update` | `mops install` |
| `mops install --lock ignore` | no successor — the lock is always
maintained |
| `mops add\|remove\|update\|sync --lock update` | `mops
add\|remove\|update\|sync` |
| `mops add\|remove\|update\|sync --lock ignore` | no successor |
| `CI=1 mops install` (implicitly meant check) | `mops install --locked`
|
| `mops test` in CI with no prior install | `mops test --locked` |
| relying on install to detect a modified `.mops/` | `mops verify` |
| `mops.lock` in a library's `.gitignore` | commit `mops.lock` |

A typical CI job becomes:

```yaml
- run: mops install --locked
- run: mops test --locked
```

`--locked` is on `mops install` and on every implicitly-installing
command: `build`, `check`, `check-candid`, `check-stable`, `test`,
`bench`, `generate candid`. `mops sources` deliberately has none — the
dfx packtool invokes it in the middle of a build and machine-parses its
stdout, so failing there is a poor place to report a stale lock. Put
`mops install --locked` earlier in the pipeline. Its stdout and the
lock's mtime are unchanged by this PR.

Dependency-mutating commands (`add`, `remove`, `update`, `sync`) have no
`--locked`: their job is to change dependencies, so they always update
the lock.

## Lockfile commit guidance is now "everyone commits"

`mops.lock created.` used to print `Libraries: add mops.lock to
.gitignore.` It now prints `Commit this file.`, and the docs say the
same. A library's lockfile cannot pin anything for its consumers — they
resolve their own graph and write their own lock — while committing it
makes the library's own CI reproducible. If you have `mops.lock` in
`.gitignore` because of the old advice, remove it.

## What `--locked` checks, and one thing it does not

`--locked` requires that the lock is present, parseable, the current
format version, pins every dependency declared in `mops.toml` to the
same value, has `deps` and `hashes` agreeing on the registry package
set, and records a hash for every file that matches the registry. It
never writes `mops.lock`.

It does **not** re-walk the dependency graph to byte-compare a freshly
computed lock, which is what I originally implemented. That design does
not work, and the reason is worth recording:

Installing from a lockfile passes `ignoreTransitive: true`, which is the
whole point — it skips downloading the dependency versions that *lost* a
version conflict. Those versions' `mops.toml` files are therefore never
in the cache, so `resolvePackages({skipLock: true})` throws on them. On
a simulated fresh clone (committed lock, cold cache, a diamond where
`base@0.10.2` loses to `base@0.14.9`) the re-walk crashed:

```
Error: ENOENT: no such file or directory, open '.../packages/base@0.10.2/mops.toml'
    at readConfig (cli/mops.ts:265:17)
    at collectDeps (cli/resolve-packages.ts:144:24)
```

The only ways to make the re-walk sound are to install the losing
versions too (giving up the lockfile's main performance benefit, in
exactly the CI case `--locked` targets) or to fetch each losing
candidate's manifest individually (a new feature, and a network call per
candidate). Neither belongs here. The walk-free checks above cover the
realistic drift instead, and published registry versions are immutable,
so a transitive version cannot change underneath a lock. The residual
gap is transitive changes reached through a local `path` dependency,
which are live directories by design.

This is the same structural limitation the resolver-correctness work hit
(#679): a valid lock short-circuits resolution, so a fresh clone never
re-walks the graph and never sees the cross-major conflict report. It
cannot be closed from the lockfile side either, for the reason above.
Recording it here rather than leaving it to be rediscovered.

## Performance

The removed re-hash was proportional to the whole dependency tree and
paid on every install, build, check and test. Measured on an 842-file /
33.5 MB tree (20 packages):

| | median warm `mops install` |
|---|---|
| before | 1.75 s |
| after | 1.59 s |

Isolated, the re-hash itself was ~136 ms (5 runs, 134–149 ms) on a warm
page cache and a local SSD. So this is an honest but modest
constant-factor win at this tree size — Node startup dominates a warm
install — and it scales linearly with tree size and degrades on cold
page cache, container overlay filesystems and networked volumes. The
primary motivation is the guarantee model; the speed-up is a secondary
benefit, and I would not sell it as the headline.

## Also in here

- Plain `mops install` now migrates a lock that still carries absolute
local `path` entries written by a pre-2.19.2 CLI. That previously
required an explicit `mops install --lock update`, which no longer
exists, so `checkLockFileLight()` treats such a lock as stale.
- Fixed `mops install` running the dependency-conflict check after a
*failed* install, which crashed with an unhandled ENOENT on manifests
the failed install never wrote. Pre-existing, but the new
download-verification failure path makes it easy to hit.
- Deleted `cli/helpers/deprecate-ci-lock.ts` (nothing else used it) and
the dead `checkRemote()` export.
- A lockfile that is valid JSON but structurally wrong (missing or
non-object `deps`/`hashes`) is treated as corrupt and self-healed,
rather than crashing with an unhandled `TypeError`. Shape is now
validated at the read boundary.
- `checkLockFileLight` (which decides whether to install from the lock)
and the `--locked` gate are derived from one `inspectLockFile` function,
so they cannot disagree. When they did, `--locked` accepted a lock that
install then declined to install from, silently falling back to
re-resolving `mops.toml`.
- Restored the `mops check-candid` `--help` description, dropped by
accident when `--locked` was wired in.

## Verification

Baseline on `kamil-v3/hidden-state`: 25 suites / 201 tests / 73
snapshots. Now 26 / 229 / 73, all green (the count includes tests
arriving with the merged base). `npm run check` and `npm run lint`
clean.

New tests in `cli/tests/locked.test.ts` (targeted assertions, no
snapshots, per AGENTS.md) cover: missing lock, missing lock on each of
`build`/`check`/`check-stable`/`test`/`bench`, up-to-date lock left
byte- and mtime-identical, changed manifest, unparseable lock, legacy
format, locked version disagreeing with `mops.toml`, locked hash
disagreeing with the registry, and the install-tolerates /
verify-rejects pair. `mops verify` gets happy path, missing lock and
not-installed cases. They use their own fixtures because Jest runs test
files in parallel and sharing `install/success` races on `mops.lock`.

Two existing tests changed intent deliberately:

- `build.test.ts` "fails on a locally modified `.mops/` file" became
"tolerates ... that mops verify rejects" — that is the guarantee change.
- `local-path-lock.test.ts` "`--lock update` rewrites absolute local
paths" became "plain install rewrites ...".

Beyond the suite, I ran a 25-case matrix by hand against the live
registry covering the same ground plus `CI=1` no longer implying check,
`--lock` being rejected on all five commands, `--locked` being accepted
on all seven implicit commands, `mops sources` having none, and the
fresh-clone cold-cache path that caught the design flaw above.

Stacked on #677, and synced onto its reworked head. Two conflicts, both
resolved in favor of the newer decisions:

- `cli/cli.ts` — #676 made unknown flags before `--` strict by removing
`allowUnknownOption(true)`. Kept that; `--locked` is a declared option
so strict parsing accepts it, and the `-- <tool flags>` passthrough
still works (verified for `check --nope`, `build --locked`, `test --
-Werror`).
- `cli/CHANGELOG.md` — kept all twelve entries from #677/#676. Dropped
this branch's network-relocation entry, superseded by #677 deleting
`set-network`/`get-network` outright. Kept this branch's version of the
entry for the seven implicitly-resolving commands; #677's still
referenced the removed `--lock update` flag and the `.mops/` tamper
guarantee that download-time verification replaces.

`cli/integrity.ts` auto-merged to this branch's version unchanged — the
`defaultLock`/CI-lock machinery it replaced left no residue.

Confirmed against the reworked `cleanCache()` (now plain whole-tree
removal of both caches, with the `PROJECT_STATE_FILES` special case
reverted): nothing in the lock or verify work depends on it. `mops.lock`
lives at the project root, never inside `.mops/`, so a full `mops cache
clean` leaves it byte-identical, after which `install --locked`
re-downloads and re-verifies without writing the lock, and `mops verify`
passes. `.mops/` is pure derived state.

Retarget with `gh pr edit 681 --base v3` once #677 merges.
# Conflicts:
#	DEVELOPMENT.md
#	cli/CHANGELOG.md
#	docs/docs/cli/1-deps/01-mops-add.md
#	docs/docs/cli/1-deps/01-mops-remove.md
#	docs/docs/cli/1-deps/02-mops-install.md
#	docs/docs/cli/1-deps/04-mops-update.md
#	docs/docs/cli/1-deps/05-mops-sync.md
#	docs/docs/cli/5-toolchain/02-mops-toolchain-init.md
#	docs/docs/cli/7-misc/03-mops-cache.md
The v3 branch kept writing site-absolute in-body links while main moved to
relative ones (#689), so the merge reintroduced 19 of them. Docusaurus does
not version-resolve absolute paths, so once 2.x is frozen these send readers
across versions.

Relative links are anchor-checked at build time, which immediately caught a
broken one: the install page pointed at `#--conflicts` on the sources page,
whose heading renders as `--conflicts-action`. Absolute links had been
hiding it.
`dfx` is no longer part of how mops works. Replica tests and benchmarks
run on PocketIC that mops downloads and manages itself, the compiler
comes from `[toolchain] moc` and nowhere else, and `mops init` no longer
asks the registry for a package set keyed on your `dfx` version. A
project can now use every mops command with no `dfx` installed at all.

That was blocked on two things, which is why they are here rather than
in follow-ups: with the dfx-bundled PocketIC gone, an unpinned
`pocket-ic` needed a default to fall back to; and with the dfx replica
gone, `@icp-sdk/core` could finally move to 5.x, since 5.x drops the IC
HTTP API `v2` endpoints that only that replica served.

Closes #652
Closes #653

## Before / After

An unpinned project, `mops test --mode replica`:

**Before** — silently borrows dfx's replica, with a deprecation warning
that has been printed since 2.14.

```
$ mops test --reporter verbose
Test files:
• test/hello.test.mo
==================================================
Using `dfx` replica because no `pocket-ic` version is set in `[toolchain]`. The `dfx` replica is deprecated and will be removed in a future release.
Run `mops toolchain use pocket-ic 12.0.0` to pin a PocketIC version and silence this warning.
 PASS
Running test/hello.test.mo (replica)
==================================================
Tests passed
Done in 8.61s, passed 1
```

**After** — mops fetches its own default the first time and says so:

```
$ mops test --reporter verbose
Test files:
• test/hello.test.mo
==================================================
pocket-ic is not pinned in [toolchain]; downloading the mops default 14.0.0.
Run `mops toolchain use pocket-ic 14.0.0` to pin it.
 PASS
Running test/hello.test.mo (replica)
==================================================
Tests passed
Done in 9.32s, passed 1
```

and on every run after that, nothing extra:

```
$ mops test --reporter verbose
Test files:
• test/hello.test.mo
==================================================
 PASS
Running test/hello.test.mo (replica)
==================================================
Tests passed
Done in 5.35s, passed 1
```

The process it actually starts, in both the pinned and unpinned case, is
the toolchain-managed binary:

```
$ ps -Ao args | grep -E '^/\S*/pocket-ic '
/Users/…/Library/Caches/mops/pocket-ic/14.0.0/pocket-ic --port-file /var/…/pocket_ic_91518.port --ttl 60   # unpinned
/Users/…/Library/Caches/mops/pocket-ic/12.0.0/pocket-ic --port-file /var/…/pocket_ic_15736.port --ttl 60   # pinned 12.0.0
```

`mops bench` is the same, and `--verbose` now names the replica it used:

```
$ mops bench --verbose
Benchmark pipeline:
  compiler:  moc 1.3.0
  replica:   pocket-ic 14.0.0
  gc:        incremental (forced)
  context:   update
  persistence: enhanced
  profile:   Release
  optimize:  none (raw moc output)
```

## The default pocket-ic version

`DEFAULT_POCKET_IC_VERSION = "14.0.0"`, in
`cli/commands/toolchain/pocket-ic-versions.ts`. It is the version the
bundled `@dfinity/pic` 0.23.0 pins for its own postinstall, so it is the
server that client is actually tested against. When the client is
upgraded, re-run the replica tests and move the constant with it.

It is a **fixed constant compiled into the CLI, never a runtime "latest"
lookup**. That is the Caffeine offline-runtime requirement: a cache
warmed at Docker-image build time has to mean runtime never touches the
network. Verified rather than assumed — with the cache warm and every
non-loopback DNS lookup, `net.connect` and `fetch` blocked by a preload,
a full unpinned replica run completes and `mops toolchain bin pocket-ic`
still answers:

```
$ NODE_OPTIONS="--require ./no-net.cjs" mops test --reporter verbose
Test files:
• test/hello.test.mo
==================================================
 PASS
Running test/hello.test.mo (replica)
==================================================
Tests passed
Done in 5.52s, passed 1
EXIT=0

$ NODE_OPTIONS="--require ./no-net.cjs" mops toolchain bin pocket-ic
/Users/…/Library/Caches/mops/pocket-ic/14.0.0/pocket-ic
EXIT=0
```

The blocker is on the path — a command that genuinely needs the network
fails loudly under the same preload:

```
$ NODE_OPTIONS="--require ./no-net.cjs" mops toolchain info pocket-ic
NETWORK BLOCKED: fetch https://api.github.com/repos/dfinity/pocketic/releases?per_page=100&page=1
```

## Version bounds: floor only, no ceiling

`MIN_POCKET_IC_VERSION = "9.0.0"` is a hard error, and it is a
**migration guard, not a compatibility policy**. `< 9.0.0` pins really
did work in 2.x through the legacy `pic-ic` client that this PR deletes,
so without the check an upgrade produces an opaque `BinTimeoutError`
from the client instead of:

```
$ mops test
Error: pocket-ic 4.0.0 is no longer supported. mops 3.0.0 removed the legacy PocketIC client, so pins below 9.0.0 no longer work.
Run `mops toolchain use pocket-ic 14.0.0` to move to a supported version.
```

There is deliberately **no upper bound**. An earlier draft enforced a
supported range with the ceiling at the newest version we had tested.
That was wrong: mops applies no version gating to `moc`, `wasmtime` or
`lintoko` — the only version check in the CLI is `[requirements]`, which
surfaces a *dependency-declared* minimum, not a mops-maintained list of
blessed versions. A ceiling would have been a new and inconsistent
policy, and it would have made our release cadence a gate on DFINITY's:
pocket-ic 15 ships, works, and nobody can use it until we cut a release.
A newer pin is simply used; if the protocol has moved, the client's own
error says so. `latest` resolves to the actual latest, like every other
tool.

The blast radius of the `pic-ic` removal is smaller than "everything
below 9.0.0" suggests: `pic-ic@0.5.4` only speaks the 4.0.0 protocol, so
`5.x`–`8.x` pins already failed with `BinTimeoutError` today. Only
`4.0.0` and `9.x`+ ever worked.

## `@icp-sdk/core` 5.x and the vendored bundle

`4.0.2` → `5.4.0`, the same major `@dfinity/pic` 0.23.0 depends on, so
there is exactly one copy in the tree:

```
$ npm ls @icp-sdk/core
ic-mops@2.20.0
+-- @dfinity/pic@0.23.0
| `-- @icp-sdk/core@5.4.0 deduped
`-- @icp-sdk/core@5.4.0
```

With the majors aligned, `vendor:pic` passes `--external:@icp-sdk/core
--external:@icp-sdk/core/*` instead of inlining pic's own copy —
`dist/vendor/pic.mjs` drops from 1,172,937 to 614,603 bytes. Sharing one
copy is also what removes the `AnyPocketIcServer` / `AnyPocketIc` /
`AnySetupCanister` unions: they existed because the two copies produced
structurally identical but mutually unassignable `IDL` types.
`cli/tests/vendor-pic.test.ts` asserts the bundle resolves
`@icp-sdk/core` at runtime rather than carrying it.

## Migration

| If you… | Do this |
|---|---|
| pass `--replica dfx` or `--replica pocket-ic` | Drop the flag. There
is no replacement; PocketIC is the only replica. `--replica dfx` was
deprecated with a warning since 2.14. |
| relied on the dfx-bundled `moc` (no `[toolchain] moc`) | `mops
toolchain use moc <version>` once, commit `mops.toml`. Every command
that compiles now requires the pin and errors naming this fix. |
| pin `pocket-ic` below `9.0.0` | `mops toolchain use pocket-ic 14.0.0`.
|
| have `dfx = "..."` in `[package]` | Delete the line. Publishing now
fails on it — deprecation since 2.7 was docs-only, so this is a hard
break with no prior runtime warning. |
| have recorded benchmark baselines | Re-record with `mops bench
--save`. PocketIC and the dfx replica report different instruction and
heap counts, so any project that was implicitly on a dfx replica will
see a large diff on the first `--compare`. This is a change of measuring
instrument, not a regression. |
| have `"profile": "Debug"` in `dfx.json` | Nothing to do, but note
`mops bench` no longer reads it and always compiles `--release`. Those
projects were silently benchmarking debug builds. |
| ran `mops init` for the default package set | It no longer contacts
the registry; a fresh `mops.toml` has no `[dependencies]`. Use `mops add
core`. |
| point `MOPS_NETWORK=local` / `MOPS_REGISTRY_HOST` at a replica | It
has to serve the IC HTTP API `v3` synchronous-call endpoint, which is
what `@icp-sdk/core` 5.x uses for update calls. `icp` and recent `dfx`
do. The default `ic` network and `staging` are unaffected. |

## What is unchanged

Everything dfx-facing that exists for projects that *deploy* with dfx
stays, deliberately:

- `mops sources` as a `dfx.json` packtool, including the "no `--locked`,
stdout is machine-parsed" contract.
- `mops toolchain init` and the `DFX_MOC_PATH=moc-wrapper` bridge.
Removing it would mean type-checking with the pinned `moc` and deploying
with a different one. `moc-wrapper` drops `--fallback` and now requires
the pin.
- `mops init` writing `defaults.build.packtool` into an existing
`dfx.json`.
- `mops watch --deploy` / `--generate` shelling out to `dfx`, and
`readDfxJson` for canister discovery.

`mops watch`'s *replica* path did change — it went to PocketIC with
everything else.

## Verification

`cli/` Jest went from 250 passing on `origin/v3` to 254, with 29 suites
green either way. The new coverage is the part types cannot check:

- `cli/tests/pocket-ic.test.ts` — a pinned replica run, an **unpinned**
one against the new default, and the `< 9.0.0` rejection.
- `cli/tests/build-no-dfx.test.ts` — now puts a `dfx` on `PATH` that
exits 127 the way a missing one does, and asserts `mops build` and `mops
check` are unaffected. Previously it only pinned `moc` and trusted that
dfx was never reached.
- `cli/tests/vendor-pic.test.ts` — the bundle leaves `@icp-sdk/core`
external.

`npm run check`, `npm run lint` and `npm run build-docs` are clean.

## Follow-up worth flagging

`mops init` no longer pins a compiler and no longer adds dependencies,
so `mops init && mops build` in an empty directory now stops at `Tool
'moc' is not defined in [toolchain] section in mops.toml`. The error
names `mops toolchain use moc <version>` and Quick Start makes it a
numbered step, but having `mops init` offer to pin the latest `moc`
would be better — that is a network call and a ~30 MB download at init
time, which deserves its own decision rather than being smuggled in
here.
Tagging `cli-v3.0.0-beta.N` on `v3` publishes a preview to npm under the
`next` dist-tag and deploys the docs site. `npm i -g ic-mops` keeps
serving 2.x; preview users opt in with `npm i -g ic-mops@next`.

The preview path lives inside `release.yml` rather than a workflow of
its own, because npm allows one trusted publisher per package and it is
bound to that file — a second workflow could not authenticate.

## Prerelease behaviour

Detected from the parsed version, not the tag text, so a malformed tag
aborts instead of falling through to the stable path.

| | stable | prerelease |
|---|---|---|
| tag must be on | `main` | `v3` |
| npm dist-tag | `latest` | `next` |
| cli-releases canister | uploaded | skipped |
| artifacts commit | yes | skipped |
| GitHub release | release | prerelease |
| docs deploy | yes | yes |

After publishing a preview the job asserts `latest` is not a prerelease,
so a mistake here cannot silently redirect every `npm i -g ic-mops`.

Worth knowing: `cli-v*` already matched `cli-v3.0.0-beta.1` before this
change, so tagging a prerelease would have run the full stable path —
publishing to `latest` and bumping the production pin.

## Docs

`lastVersion` moves to `2.x`, so the released line serves at the site
root and the in-development line at `/next`. Flip it back at GA.

Docs deploy moves from dfx to icp-cli. It is the smaller of the two
mainnet deploys and the natural first one to move; the cli-releases
canister deliberately stays on dfx so only one production path changes
at a time.

The deploy asserts that `https://docs.mops.one/.well-known/ic-domains`
still names the domain. That file is served only because
`docs/static/.ic-assets.json` opts dot-directories into the upload, and
losing it takes the custom domain offline. Rollback is the previous
command:

```
dfx deploy --network ic --no-wallet docs --identity mops
```

## Known interaction: one docs canister, two branches

Both `main` and `v3` deploy the docs canister, and they carry different
`lastVersion` values, so whichever released most recently decides the
layout. A stable 2.x release during the preview period restores
3.x-at-root and removes `/next`, invalidating `/next` links in earlier
preview notes. Recovery is re-running the v3 docs deploy; it resolves
itself at GA when the configs converge.

Not gated, because gating `main` would stop docs-only fixes there from
ever publishing. Landing this `lastVersion` on `main` was also rejected:
`main`'s `docs/docs` *is* the 2.x content, so the flip would serve the
frozen snapshot at the root and push main's live docs to `/next`
labelled "3.x (unreleased)".

## Unproven until the first real tag

No mainnet deploy is exercisable from CI, so these run for the first
time when a preview is tagged:

- `icp identity import` on a headless Linux runner
- `icp canister link` against a runner with no ID store
- `icp deploy` to the docs canister, and whether its uploader honours
`.ic-assets.json` for `.well-known`
- the npm publish and the dist-tag read-back
- the prerelease `if:` conditions, which were read off the parsed
workflow but never triggered

Verified locally: the docs build with both URL trees present, prerelease
detection across valid and malformed versions, `npm version` keeping
`package-lock.json` in sync, and icp-cli importing the existing dfx pem
to the same principal — so `MOPS_IDENTITY_PEM` needs no change.

## Follow-up

`icp canister link` writes `.icp/data/mappings/<env>.ids.json` and
`.gitignore` only covers `.icp/cache/`, so local deploy state is
committable. Pre-existing since #550.
Deploying now needs `icp`, never `dfx`. `dfx.json`, `dfx.schema.json`
and
`canister_ids.json` are deleted; `icp.yaml` is the only deploy config
left. The
commands keep their names:

```bash
npm run deploy-staging          # main + assets on the staging canisters
npm run deploy-ic               # everything the ic environment declares
npm run deploy-ic blog          # or one canister
```

Canister IDs move into icp-cli's own store. It cannot declare an ID in
the
manifest, but it splits its state: `.icp/cache/` is machine-local, while
`.icp/data/mappings/<environment>.ids.json` is
[meant to be
committed](https://cli.internetcomputer.org/1.2/migration/from-dfx/#4-migrate-canister-ids-optional),
which is what lets a fresh clone deploy without relinking. Both mainnet
mappings
are in this PR. Deploys pass `--no-create`, so a missing mapping fails
loudly
instead of creating a second production canister.

Three IDs `canister_ids.json` held — `dao-frontend`, `dao-backend`,
`play-backend` — have no source in this repo and nothing deploys them.
Dropped
rather than carried; git history has them.

Follow-up to [#692](#692),
which moved
the docs canister and deliberately left everything else on dfx.

## Two footguns closed on the way

`icp.yaml` now declares both mainnet environments explicitly rather than
inheriting "every canister". `ic` excludes `bench`, which `dfx.json`
kept off
mainnet with a `remote` id. `staging` covers only `main` and `assets`:
`docs`,
`blog` and `cli` have no staging canister of their own, so a bare
`deploy-staging` was a production deploy under another name. `network:`
on an
environment defaults to `local`, so an environment named `ic` without it
would
deploy nothing, quietly — hence the comment in the file.

The frontend selects its network with `MOPS_FRONTEND_NETWORK` instead of
`DFX_NETWORK`, and it is required — no silent default. Deliberately
*not*
icp-cli's own `ICP_ENVIRONMENT`: `-e` wins for icp but an exported
`ICP_ENVIRONMENT` still reaches vite, so the two could disagree and bake
local
replica ids into a mainnet bundle. `npm run deploy` derives it from the
value
it passes to `-e`, so they cannot; a raw `icp deploy assets` leaves it
unset
and fails instead of shipping. Previously a build with no network set
took ids
from the local replica while baking `MOPS_NETWORK = "ic"`.

## The build assertion was decorative

`frontend/verify-build.mjs` checked that the bundle contained *some*
principal-shaped string. But `cli/api/network.ts` hardcodes the ic and
staging
endpoint ids and the frontend bundles that module, so a principal is
always
present — the check passed on a build with no canister mappings at all,
which
is precisely the bundle it exists to reject. Confirmed by deleting the
mappings
and rebuilding: exit 0, no ids baked in. It now asserts the specific
`main` id
read from the mappings file, and fails without them.

## The composite action is parameterised, not duplicated

`deploy-docs` becomes `deploy-canister`, taking the canister name, its
custom
domain, and the directory whose `npm ci` its build step needs. A second
action
would mean a copy of the identity import and the retry loop, and both
canisters'
baseline health check is the same assertion — the custom domain resolves
only
while `.well-known/ic-domains` is served, and that file is only uploaded
because
an `.ic-assets.json` opts dot-directories in. What is specific to the
`cli`
deploy stays in `release.yml`: `/tags/latest` must name the new version
and both
tarballs must unpack, which is what `mops self update` and the
`mops-test.yml`
matrix consume.

icp-cli and the identity are installed and imported once for the job,
not once
per canister. Both deploys run on the same runner, so a per-canister
import
would only give `icp identity import` — which has no `--force` — an
existing
name to fail on, and it keeps `MOPS_IDENTITY_PEM` to a single step
instead of
passing it across the action boundary twice.

## What is unchanged

Everything in `cli/` that supports dfx *users* — `mops sources` as a
packtool,
`mops toolchain init`, `mops watch --deploy`, `mops init`'s packtool
write — is
untouched. A separate PR removes those, and it has to land after this
one: we
cannot tell users to drop dfx while our own release still needs it.

`setup-dfx` stays in `mops-test.yml` and `setup-mops.yml`, which was the
one
thing I expected to delete. Those jobs need dfx as a *replica*, not a
compiler:
`test/storage-actor.test.mo` runs in replica mode, `mops.toml` pins no
`[toolchain] pocket-ic`, and mops 1.x/2.x fall back to the dfx replica.
Pinning
`pocket-ic` would not rescue `setup-mops.yml`, which runs mops 1.0.0,
and 1.0.0
speaks only the PocketIC 4.0.0 API. Both jobs now pass `dfx-version`
explicitly,
because `setup-dfx`'s `auto` mode reads it out of `dfx.json`
(`jq -er '.dfx // ""' dfx.json`) and with that file deleted would have
silently
installed the latest dfx. In `mops-test.yml` the install is skipped for
the
current CLI, so a regression that made 3.x need dfx fails there instead
of
passing on a borrowed binary.

Argument forwarding through the nested `npm run` also survives — a
previous AI
review flagged it as broken and it is not. npm 10 appends positionals to
the
already-named inner script rather than reading them as a script name:

```
$ npm run deploy-ic blog
WOULD RUN: icp deploy -y --no-create --identity mops -e ic blog
```

## Rollback

The `cli` canister, from a checkout of the previous release commit:

```bash
icp deploy cli -e ic --identity mops --no-create --yes
```

Same shape for `docs`. The failure message in the action prints it too.

## Unproven until a real release

Neither canister deploy can be exercised against mainnet from a PR. The
local
replica covered the rest, including a full `cli` deploy through
`icp.yaml`'s
`cli` entry — the first time it has actually run. Untested until the
next
`cli-v*` tag:

- `icp deploy --no-create` upgrading the live `cli` and `docs`
canisters, and
the asset sync diffing against their existing contents rather than an
empty one.
- The identity import from `MOPS_IDENTITY_PEM`, and the
delete-then-import that
  now runs for the second canister.
- Every post-deploy assertion against the real `cli.mops.one` /
`docs.mops.one`.
- `npm run deploy-staging` / `deploy-ic`, which no workflow runs.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Mops no longer supports `dfx` in any way, and this repo no longer
installs it anywhere.
[#691](#691) took dfx out of
mops's own internals — the dfx replica, the dfx-bundled `moc` fallback,
`mops init`'s dfx default packages. What it deliberately left behind was
the surface that existed to serve users who *deploy* with dfx. That goes
now.

Gone: `mops toolchain init` and `mops toolchain reset`, the
`moc-wrapper` binary the published package installed, `mops watch
--deploy` and `mops watch --generate`, and `mops init` writing a
packtool entry into a `dfx.json` it happens to find.

`mops sources` stays, unchanged down to its stdout, because it is
tool-agnostic — it prints `--package` flags and has no opinion about who
reads them.

## The repo's own last dfx dependency goes too

`mops-test.yml` and `setup-mops.yml` were the only workflows still
installing dfx, and only ever as a *test replica* for
`test/storage-actor.test.mo`. Both are gone, so nothing in this repo
touches dfx.

The published 2.x CLI picks its replica with
`config.toolchain?.["pocket-ic"] ? "pocket-ic" : "dfx"` ([cli-v2.20.0
`test.ts:68`](https://github.com/caffeinelabs/mops/blob/cli-v2.20.0/cli/commands/test/test.ts#L68))
— it reached for dfx purely because `mops.toml` pinned no version.
Pinning `[toolchain] pocket-ic = "14.0.0"` switches it to PocketIC, and
2.x and 3.x ship the same `@dfinity/pic` 0.23.0 client, so one pin
serves the whole matrix.

`setup-mops.yml`'s `mops-version: 1.0.0` dated from 2024 (`cfa434ce`)
and was the one version no pin could rescue — 1.0.0 speaks only the
PocketIC 4.0.0 API. Dropping the input lets the action install its
`latest` default, which is what that workflow exists to test.

Verified with `dfx` stubbed to exit 127 and print on invocation:

| | result |
|---|---|
| 3.x CLI, pin | 122 passed, `storage-actor.test.mo (replica)` included,
stub untouched |
| 2.20.0, pin | 20 files passed, replica cases included, stub untouched
|
| 2.20.0, **no** pin (control) | ``Using `dfx` replica because no
`pocket-ic` version is set`` → `DFX WAS INVOKED: --version` |

The control matters: without it, a green run only proves the tests pass,
not that removing dfx is what made them stop using it.

## Migration

**If you deploy with dfx, read this.** `mops sources` still works as a
`dfx.json` packtool, so dfx keeps resolving your mops dependencies. What
no longer reaches dfx is the *compiler*.

`mops toolchain init` existed to write `export DFX_MOC_PATH=moc-wrapper`
into your shell config; `moc-wrapper` then resolved the `moc` pinned in
`[toolchain]` and handed it to `dfx build`. Without that bridge, `dfx
build` uses the `moc` bundled with dfx, while `mops check` / `mops
build` / `mops test` use your pinned one. **Type-checking and deploying
with two different compilers is a real hazard** — a program that passes
`mops check` can still fail to build, or build differently, under `dfx
deploy`. It is the direct consequence of this change and worth being
explicit about rather than discovering later.

The supported path is [`icp`](https://js.icp.build/), whose Motoko
recipe builds each canister by invoking `mops build`, so the pin
propagates with nothing in between. If you stay on dfx, set
`DFX_MOC_PATH` yourself and keep the compiler it points at in step with
`[toolchain] moc`.

Everything else:

| Removed | Do instead |
|---|---|
| `mops toolchain init` / `reset` | Nothing to run. Remove the `export
DFX_MOC_PATH=moc-wrapper` line `init` added to your shell config. |
| `mops watch --deploy` | `icp deploy` (or `dfx deploy`) in a second
terminal. |
| `mops watch --generate` | `icp-bindgen`, or `dfx generate`, in a
second terminal. |
| `mops watch -tgd` | `mops watch -t`. Since
[#676](#676) rejects unknown
options, the bundle now fails with `unknown option '-gd'` rather than
quietly ignoring the two dead letters. |
| `mops init` writing `defaults.build.packtool` | Add `"packtool": "mops
sources"` under `defaults.build` by hand. |

## Before / after

`mops toolchain --help`:

```diff
 Commands:
-  init [options]         One-time initialization of toolchain management
-                         (updates the current shell's config file)
-  reset                  Uninstall toolchain management (cleans all known shell
-                         config files)
   use <tool> [version]   Install specified tool version and update mops.toml
   update [tool]          Update specified tool or all tools to the latest
                          version and update mops.toml
   info [options] <tool>  Show release information about a toolchain tool
   bin <tool>             Get path to the tool binary
```

```
$ mops toolchain init
error: unknown command 'init'
(Did you mean info?)
```

`mops watch --help`, before:

```
Watch *.mo files and check for syntax errors and warnings and format code. Pass
flags to run only the selected tasks; --test, --generate and --deploy are opt-in
only

Options:
  -e, --error     Check Motoko canisters or *.mo files for syntax errors (on by
                  default)
  -w, --warning   Check Motoko canisters or *.mo files for warnings (on by
                  default)
  -f, --format    Format Motoko code (on by default)
  -t, --test      Run tests (opt-in)
  -g, --generate  Generate declarations for Motoko canisters (opt-in)
  -d, --deploy    Deploy Motoko canisters (opt-in)
  -h, --help      display help for command

With no flags, runs the default set: errors, warnings and formatting.
Passing any flag runs only the selected tasks (error checking is always on).
Tests, declaration generation and deploys never run unless requested:
  $ mops watch -t     # errors + tests
  $ mops watch -tgd   # errors + tests + generate + deploy
```

after:

```
Watch *.mo files and check for syntax errors and warnings and format code. Pass
flags to run only the selected tasks; --test is opt-in only

Options:
  -e, --error    Check *.mo files for syntax errors (always on)
  -w, --warning  Check *.mo files for warnings (on by default)
  -f, --format   Format Motoko code (on by default)
  -t, --test     Run tests (opt-in)
  -h, --help     display help for command

With no flags, runs the default set: errors, warnings and formatting.
Passing any flag runs only the selected tasks (error checking is always on).
Tests never run unless requested:
  $ mops watch -t     # errors + tests
  $ mops watch -tw    # errors + tests + warnings
```

The `-e` / `-w` descriptions said "Motoko canisters or *.mo files"
because canister discovery came from `dfx.json`. Both checkers ignored
that list entirely — they glob `**/*.mo` — so the field was dead as well
as misleading, and it is dropped along with `parseDfxJson.ts`.

`mops sources --help`:

```diff
-for dfx packtool
+Print the resolved dependencies as `--package` flags for the Motoko compiler
```

## Why `--generate` was dropped rather than ported

`--deploy` being dropped was decided up front: it shells out to `dfx
ping` / `dfx canister create` / `dfx build` / `dfx canister install`,
and mops is not a deployment tool.

`--generate` needed a decision. It turned out to be a thin wrapper
around `dfx generate <canister>` — a dfx binary invocation, not merely a
`dfx.json` read — with the canister list coming from `dfx.json` entries
carrying a `declarations` field. `dfx generate` emits
JavaScript/TypeScript bindings, which mops has never produced: `mops
generate candid` writes `.did` files and nothing more. Re-pointing it at
`[canisters]` in `mops.toml` would not have helped, because there is no
mops-side generator behind it. So it goes with the deployer.

`globMoFiles.ts` also matched a `dfx` grep, but only in its ignore list
(`**/.dfx/**`, alongside `**/node_modules/**`). Skipping a build
directory a user may still have on disk is not dfx support, so it stays.

## What is unchanged

- **`mops sources`** — same behaviour, same flags, and stdout verified
byte-identical against `origin/v3` on a fixture project. The note that
its stdout is machine-parsed (hence `lock: "skip"` and no `--locked`) is
kept; that constraint holds for any caller, not just dfx.
- **`mops publish` rejecting `package.dfx`** — a migration error for a
field v3 removed, not dfx support.
- **`mops user import` accepting dfx-exported keys** — a PEM is a PEM.
The docs now lead with `icp identity export` and note dfx's works too.
- **`cli/tests/build-no-dfx.test.ts`** — still valuable as a regression
test that mops works with no `dfx` on `PATH`.
- **The repo's own `dfx.json` and deploy scripts** — out of scope here,
see below.

## Sequencing

Must not merge before
[`kamil-v3/deploys-on-icp`](https://github.com/caffeinelabs/mops/tree/kamil-v3/deploys-on-icp),
which moves this repo's own deploys off dfx. Telling users to drop dfx
while our release pipeline still needs it would not be credible. Expect
a conflict in `AGENTS.md`, and possibly `DEVELOPMENT.md`, since both
branches touch the dfx guidance.

## Packaging

`bin/moc-wrapper.sh` was the only reason `/bin` appeared in `files`, so
both are dropped from `cli/package.json`. `npm pack --dry-run` confirms
the tarball no longer carries `moc-wrapper` in any form;
`dist/bin/mops.js`, which the `mops` and `ic-mops` bins point at, is
unaffected.

## Verification

`cli/` Jest: **29 suites, 257 tests, 75 snapshots**, all passing —
against 29 / 254 / 75 on `origin/v3`. The count went up, not down: no
tests were deleted, because none existed for any of the removed
surfaces. Worth noting on its own — `mops toolchain init`, `mops watch
--deploy` and the `sources` description were all uncovered. The three
new ones cover `toolchain init` / `toolchain reset` being unknown
commands and `mops watch --help` no longer advertising the dfx tasks.
`npm run check` and `npm run lint` are clean.

Manually: `mops toolchain init` and `reset` both report `unknown
command`; `mops watch` still runs errors, warnings and formatting to
completion; `mops init --yes` in a directory containing a `dfx.json`
leaves that file byte-for-byte untouched.

The three snapshot files changed only because the path placeholder in
`tests/helpers.ts` was literally `moc-wrapper` — a name that no longer
refers to anything. It is now `<MOC>`. No assertion changed.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Same guard as [#696](#696), on
the v3 line: a major jump in `mops self update` prints the release-notes
link and asks for confirmation; non-interactive environments skip the
update with a notice and exit 0 (erroring would break scripted updates
the day a major ships), naming `mops self update --major`; an
unparseable `/tags/latest` body is an error instead of an npm install
spec. Same-major updates are unchanged.

#696 is what partially protects the 2.x→3.0 jump (client-side check, so
only users who update into it get it). This one covers 3.x→4.x
permanently.

Decision table in `helpers/self-update-kind.ts` with unit tests for the
prerelease edges — `2.20.0 → 3.0.0-beta.1` prompts, `3.0.0-beta.1 →
3.0.0` does not, which matters while previews ship from this branch.
Full v3 suite passes (26 suites, 265 tests, 75 snapshots).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Housekeeping from the pre-GA audit. Three files, no code.

**NEXT-MAJOR.md** — two lockfile bullets shipped with the `--locked`
work (#516) but were never struck; struck now with what actually
shipped. The "decide before release" custom-registry question is
recorded as decided: **keep as supported** — `MOPS_REGISTRY_HOST` /
`MOPS_REGISTRY_CANISTER_ID` are already documented in the 3.x env-vars
page, so dropping them would now be the breaking change. With this, the
only remaining unstruck items in committed scope are non-gating (GH
#274, the release blog post, upstream-gated #651/#655).

**cli/DEVELOPMENT.md** — was an old release doc wearing the wrong name:
"Publish to npm", "Publish on-chain", ending in a `dfx deploy --network
ic` that no longer exists (`dfx.json` is deleted; the pipeline owns
deploys). Rewritten as an actual development doc: prerequisites with the
reason each exists (gnu-tar is for `bundle:tar`'s `--sort name`), the
dev loop, and reproducible-build verification pointing at `build.sh`.

**cli/RELEASE.md** — the manual-deploy fallback still ran `dfx deploy`;
now `npm run deploy-staging <canister>` / `deploy-ic <canister>`. Adds a
**Preview releases** section, since that's how every 3.0.0 beta ships
and it was documented nowhere: manual tag, and the exact set of
deviations from a stable release (npm `next` dist-tag, GitHub
prerelease, no `cli` canister upload so `mops self update` keeps serving
stable, docs still deploy — previews are what publish `/next/`).

## Left alone deliberately

`cli-builder/` still points at the `zenvoich/mops-builder` Docker Hub
image. The `cli/Dockerfile` pins it by sha256 digest, so
reproducible-build verification doesn't trust the registry — but the
image is owned by an account nobody here controls, so it can't be
updated, only replaced. Moving to a caffeinelabs-owned image is infra
work, not docs; flagging it as an open decision rather than half-doing
it here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes [#685](#685). The
`3.0.0 (unreleased)` section is now release notes rather than a merge
log.

**Structure**: a `### Migrating from 2.x` preamble — every action item
in one list, one line each — followed by themed sections (dfx removal,
PocketIC and toolchain, lockfile and integrity, dependency resolution,
defaults and CLI strictness, removals, runtime). Every breaking entry
leads with what to do; rationale is cut to a sentence with the PR
reference carrying the detail.

**Defects fixed, not just style** — the merge-order accretion had
produced things a reader must never see:

- **Duplicates**: Node >= 20 and the mocv removal were each listed
twice.
- **Never-shipped states**: the per-project `set-network` storage
(`.mops/network`, `--global`) and the single-shell `toolchain init`
rewrite both landed on v3 and were later superseded by outright removal
on the same branch. A 2.20 → 3.0 reader would have "migrated" to
commands that don't exist.
- **Self-contradiction**: the `mops watch` entry advised reproducing the
old default with `-wtgd`, but `-g`/`-d` are removed two entries up —
following the advice produces `unknown option`.
- **Removed-flag reference**: the exit-code-11 entry described
`--replica dfx` bind failures; the flag and the code path are both gone,
so the entry is subsumed by the dfx-removal section.

22.7k → 15k characters. Every technical claim that describes shipping
behavior survives, including the fine-print ones (path-dep transitives
not detected by `--locked`/`verify`, the hand-edited-hash limitation and
its costed rationale, the `@icp-sdk/core` v3-endpoint requirement for
custom registries).

Verification was line-by-line against the old section; nothing was
verified against code beyond what the original entries already claimed,
except the exit-11 path (checked gone on v3 before dropping the entry).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ncher pin) (#702)

Brings v3 level with main. Three changes flow in, one needing a by-hand
carry:

- **[#699](#699) — decompress
removal.** The call site moved on v3 (`cli/vessel.ts` →
`cli/commands/install/install-from-github.ts`), so the extractor swap
was carried by hand: main's `extractGithubZip` with v3's import depths.
Verified on v3 by installing a real GitHub dep
(`dfinity/motoko-base#moc-0.14.14`) — extracted at the right paths, lock
integrity passes. `npm audit` clean on the merged tree.
- **[#696](#696) — self-update
guard.** Already on v3 via
[#697](#697); the merge
resolves to one `prompts` import and otherwise identical code.
- **[#700](#700) —
network-launcher pin.** `icp.yaml` merged clean; AGENTS.md's icp-cli
bullet takes main's reworded version on top of v3's dfx bullets.

**Changelog resolution**: v3's `## Next` gains the decompress entry (the
release workflow rolls `Next` into the version heading at release time,
so it lands in 3.0.0 automatically) and keeps the 2.x-only moc-wrapper
note; main's self-update entry is dropped from `Next` because #697
already placed it in the `3.0.0 (unreleased)` section. The 3.0.0 section
itself is untouched, so this does not conflict with
[#701](#701).

`cli/package-lock.json` regenerated from the merged `package.json`
rather than resolved textually — the ~500-line shrink is `decompress`'s
transitive tree leaving.

Full merged suite: 269/269, 75 snapshots. (Two initial failures were a
stale local `dist/` predating the merge, green after rebuild — same
artifact-staleness mode as in #699's development, not a code issue.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…y`) (#703)

Syncs [#644](#644) into v3. The
feature merges mostly clean; the interesting part is that it was written
against 2.x's dual-PocketIC-client world, which v3 deleted — so this is
a port, not just a conflict resolution.

## What is deliberately not carried

`#644` routed `--check-deploy` through a `client: "dfinity"` selector
plus `assertDfinityClientSupportsPocketIc`, because on 2.x the default
client path could pick the legacy `pic-ic` (< 9.0.0), which cannot drive
deployment checks. On v3 the upstream client is the only client and both
halves of that guarantee already exist globally: pins below 9.0.0 are
rejected at binary resolution with the migration message, and **no pin
resolves to the compiled-in default** — so `--check-deploy` on v3 needs
no pin at all, and requiring one (as #644's error did) would contradict
v3's own changelog entry. The selector, the `PocketIcResult` union and
the assert are gone; `pocket-ic-startup.ts` is not carried.

What **is** carried from that module: `createClientOrStopServer` — a
server whose client never came up is an orphaned process on any client —
now in `pocket-ic-client.ts` with its unit tests.

## Behavior differences from #644 on main, both intentional

| 2.x (#644) | v3 (this PR) |
|---|---|
| `--check-deploy` with no `pocket-ic` pin: error, "requires
\`pocket-ic\` in \`[toolchain]\`" | Works — runs on the default version
|
| `--check-deploy` with a `< 9.0.0` pin: rejected **before** building by
the assert | Rejected at pocket-ic resolution (**after** the build) by
the global floor guard, with the standard migration message |

The two version-gating tests were rewritten to assert exactly that, and
the `check-deploy-legacy` fixture gained buildable source — it never
needed any on 2.x because the assert fired before compilation ever ran.
Docs (`03-mops-build.md`), the skill and the changelog entry are
reworded where they claimed a 9.0.0+ pin is required.

## Changelog resolution

Same convention as
[#702](#702): #644's entries
land in v3's `## Next` (rolled into the version heading at release), the
pin-requirement sentence adjusted; the self-update and moc-wrapper
duplicates are dropped (already present on v3). `cli/package-lock.json`
regenerated by restoring v3's lock and applying the delta with a real
install — the platform-optionals lesson from #702.

Full suite on the merged tree: 302/302, 77 snapshots, eslint and
prettier clean. That count includes #644's check-deploy integration
tests running against real PocketIC on v3's client for the first time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Michael Morandi <michael.morandi@caffeine.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Brings the 2.21.0 release and
[#706](#706) (released 2.x line
at the docs root) onto `v3`.

## Conflicts

Sixteen files conflicted. Thirteen are squash-merge artifacts — `main`
commits already applied to `v3` by #702/#703 re-presented because squash
merges leave the originals unreachable — and were resolved to `v3`'s
side unchanged. The three real ones:

**`cli/CHANGELOG.md`** — `main` rolled `## Next` into `## 2.21.0`;
`v3`'s five `## Next` entries are exactly those, now released. Took
`main`'s file and reinserted `v3`'s `## 3.0.0 (unreleased)` section
between `## Next` and `## 2.21.0`. Also dropped the `mops self update`
major-guard bullet from the 3.0.0 section — it shipped in 2.21.0, and no
other 2.x-released entry is repeated under 3.0.0. Resulting heading
order: `Next` (empty) → `3.0.0 (unreleased)` → `2.21.0` → `2.20.0`.

**`docs/docusaurus.config.js`** — values identical on both sides. Took
`main`'s comment: `v3`'s said "`main` carries `'current'`", which #706
made false.

**`cli/vessel.ts`** — resurrected again by rename detection pairing it
with `install-from-github.ts`. Deleted; nothing imports it. Confirmed it
was the only re-added file via `git diff --diff-filter=A origin/v3
HEAD`.

## Lockfile

`cli/package-lock.json` carries `v3`'s dependency graph with the version
field aligned to `package.json`'s `2.21.0`. Edited by hand rather than
regenerated: `npm install --package-lock-only` on macOS prunes the
cross-platform `@esbuild/*` optional entries and breaks Linux `npm ci`.
`cli/package.json` differs from `v3` only in that version field, so
`v3`'s graph is already correct.

## Verification

- `npm ci` from scratch in `cli/` — clean, 0 vulnerabilities
- `npm run check` (`tsc --noEmit`) — passes
- No files resurrected by rename detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Michael Morandi <michael.morandi@caffeine.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: caffeine-ci-bot[bot] <249119985+caffeine-ci-bot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…708)

The legacy asset recipe cannot redeploy a canister that already holds
assets: its sync plugin either exhausts its sandbox memory after minutes
of silence or emits a create-everything batch that traps with `'asset
already exists'`
([dfinity/icp-cli#703](dfinity/icp-cli#703)).
That is what broke the docs deploy in the `cli-v3.0.0-beta.1` release
run — 37 silent minutes, then the job timeout. Only the empty-canister
path works, and a reinstall buys exactly one deploy before the next one
fails again.

This moves `docs` to
[`@dfinity/static-site@v0.3.3`](https://github.com/dfinity/certified-assets)
— dfinity's current certified-assets canister, which Raymond pointed at
([skill](https://skills.internetcomputer.org/skills/static-site/)). The
recipe tag pins the canister wasm and its sync plugin as a matched pair,
and redeploys work incrementally, change detection included.

Also gained in the move:

- Unknown paths return a real **404**, using the `404.html` Docusaurus
already builds. The legacy canister answers them with the root
`index.html` at HTTP 200 — the soft-404 that forces every deploy
assertion in `release.yml` and `.github/actions/deploy-canister/` to
grep response bodies instead of statuses, and that served
`/.ic-assets.json` as the intro page.
- `.well-known/` uploads despite the leading dot, so the custom domain
resolves with no opt-in file. `docs/static/.ic-assets.json` existed only
to force that upload and is deleted — it was load-bearing for
`docs.mops.one` and easy to delete by accident.
- ETags on every response and certified 206 range responses (legacy
sends neither).

## `trailingSlash: false` is required, not incidental

The new canister canonicalises clean URLs. With Docusaurus's default
output (`quick-start/index.html`) the canonical form becomes
`/quick-start/`, so every deep link would 307 to a URL that disagrees
with the `<link rel="canonical">` Docusaurus itself writes. `false`
makes Docusaurus emit `<route>.html`, which the canister serves at the
extension-less URL — byte-identical to today's URLs, at 200, with no
redirect hop. Both variants were built and deployed to confirm this.

## Verification

Full cutover simulation on a local replica, in production order — and
step 3 is the exact case the legacy recipe fails at:

| step | result |
|---|---|
| deploy with the legacy recipe (mainnet's state today) | 230 assets,
14s |
| switch manifest, `icp deploy -m reinstall` | 230 assets, 21s |
| **normal deploy on the populated canister** | 5s — change detection,
no re-upload |
| content change, normal deploy | 6s — new file served |
| one more repeat deploy | 5s |

Path parity against the legacy recipe over the same 230-asset build:
`/`, `/quick-start`, `/next`, `/mops.toml`, `/sitemap.xml`,
`/img/logo.svg` and `/.well-known/ic-domains` serve identically;
`/does-not-exist` returns the Docusaurus 404 page instead of a soft 200;
`/.ic-assets.json` no longer leaks.

The `.well-known/ic-domains` assertion in
`.github/actions/deploy-canister/` passes with no `.ic-assets.json`
present. Neither canister sets `Cache-Control`, so the migration is
header-neutral; a `_headers` file for content-hashed `/assets/*` is a
follow-up, deliberately not bundled here.

## Cutover

Not a plain deploy — the wasms have incompatible state, so `icp
deploy`'s default `auto` mode picks an upgrade that cannot work.
One-off, with a snapshot as the rollback handle (snapshots require the
canister stopped; a stopped asset canister keeps serving queries, so
this is not an outage):

```
icp canister stop docs -e ic --identity mops
icp canister snapshot create docs -e ic --identity mops
icp canister start docs -e ic --identity mops
icp deploy docs -e ic --identity mops --no-create -m reinstall
```

The canister ID — and with it the custom-domain registration — survives;
the cost is downtime for the length of the sync (~21s locally). Rollback
is `snapshot restore`, which brings back the old wasm and assets
together. Subsequent releases are ordinary deploys.

## Interaction with `main`

[#709](#709) (merged) already
removed `main`'s dfx docs deploy, which could not drive the new wasm.
`v3` owns the docs canister: its build is a superset of `main`'s — the
same `docs/versioned_docs/version-2.x/` at the site root plus the
in-development line under `/next` — so 2.x doc edits reach production
once merged into `v3`.

## Not migrated

`assets`, `blog` and `play-frontend` are unchanged here. `cli` cannot
move yet — `static-site` takes a single `dir` and that canister serves
two (`cli-releases/` and `cli-releases/frontend/dist/`). Note
`static-site` is 0.x, where a minor bump is breaking by its own policy
(reinstall + full re-upload) — read release notes before moving the pin.
All of this is written down in AGENTS.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
`[optimize]` without a `[toolchain] wasm-opt` pin made `mops build` and
`mops bench` ask GitHub for the latest Binaryen release and write the
result into mops.toml. Two things a build must not do: mutate checked-in
source, and let the network decide what it compiles with. It also made
builds unreproducible by construction — the same commit built either side
of a Binaryen release produced different artifacts.

Build commands now fail before compiling anything and name the fix,
matching how an unset `[toolchain] moc` is already handled. Validation
runs in a preflight next to the existing canister checks, so a
misconfigured project fails in milliseconds rather than after moc runs.

A `wasm-opt` failure now fails the build too, instead of warning and
keeping the unoptimized module. `[optimize]` describes the artifact, so
substituting a different one left nothing for a downstream consumer that
hashes, certifies or deploys it — and the warning scrolled past in CI.

Adds a reproducibility test: two builds of one fixture into separate
output dirs must produce identical .wasm/.did/.most. Verified passing,
which also establishes that moc plus candid-metadata embedding is
deterministic — previously unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Cursor AI review

👍 APPROVE — looks safe to merge

Category Assessment Details
Summary Docs-only addition of docs/docs/articles/04-driving-mops-from-another-build-system.md: a CI/build-system guide covering --locked/verify, writes, network, exit codes, and moc arg assembly. No code or behavior changes.
Code Quality Single focused article; avoids speculative --offline / build-record features (YAGNI). Matches existing article frontmatter/slug pattern.
Consistency Autogenerated sidebar (docs/sidebars.js) picks up 04-… without manual registration; --locked command list and sources no---locked rationale match cli/cli.ts and cli/CHANGELOG.md.
Security Accurately documents the 3.0 integrity split: traced checkIntegrity/assertLockedUpToDate vs verifyIntegrity in cli/integrity.ts, and best-effort notifyInstalls swallow in cli/notify-installs.ts. No auth/integrity code changed.
Tests No new tests required (docs-only). Behavioral claims cross-checked against cli/tests/locked.test.ts, cli/tests/build.test.ts (reproducible artifacts), cli/helpers/moc-args.ts, and cli/commands/check.ts exit passthrough.
Maintainability Clear pipeline-oriented reference; relative link to docs/docs/cli/1-deps/06-mops-verify.md resolves.

Verdict

Decision: APPROVE
Risk: Very Low
Reason: One new documentation article with no code changes; core claims about --locked, verify, network reach, and exit-code passthrough match the current CLI implementation.


Generated for commit 3c6696c

Kamirus and others added 2 commits August 11, 2026 13:48
Everything else in the docs assumes a person at a terminal. A CI
pipeline or canister orchestrator has different questions, and today has
to reverse-engineer the answers: which commands write which files, what
reaches the network and when, what the exit codes distinguish, and how to
extract the facts a build record needs.

The warning that prompted this: `--locked` is widely assumed to detect
tampering with files on disk. It does not — 3.0.0 verifies integrity at
download time — and `mops verify` is the gate that does. That was only
recorded in the changelog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both query the registry for published file hashes on every run, so a warm
cache removes the downloads but not the query. The page claimed a warm
rebuild made no registry call at all, which would have led someone to
design a no-network pipeline that fails at the lock check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Kamirus
Kamirus force-pushed the kamil-v3/docs-build-integration branch from 6af6a28 to 61e9acf Compare August 11, 2026 11:48
@Kamirus
Kamirus changed the base branch from v3 to kamil-v3/hermetic-builds August 11, 2026 11:48

@automation-sa-sre automation-sa-sre left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: the AI review verdict for 61e9acf is APPROVE. See the "Cursor AI review" comment for details.

…710)

A project with `[optimize]` and no `[toolchain] wasm-opt` pin had its
**`mops.toml` rewritten by the next `mops build` or `mops bench`**, with
the version chosen by a "latest release" lookup against GitHub at build
time.

Two things a build must never do: mutate checked-in source, and let the
network decide what it compiles with. It also made builds unreproducible
by construction — the same commit, built either side of a Binaryen
release, produced different artifacts.

This was the last runtime "latest" lookup on the build path. `moc` and
`pocket-ic` already resolve from a pin or a compiled-in constant;
`wasm-opt` now does too.

## Before / after

```console
$ cat mops.toml
[toolchain]
moc = "1.3.0"

[optimize]

$ mops build              # before
Pinned wasm-opt 131 in mops.toml ([optimize] enabled)
build canister main
$ git diff --stat         # ...and your manifest changed
 mops.toml | 1 +
```

```console
$ mops build              # after
[optimize] is enabled but wasm-opt is not pinned in [toolchain].
Run `mops toolchain use wasm-opt 131` (or another version), or drop [optimize] from mops.toml.
Pass --no-optimize to skip the pass for a single run.
$ echo $?
1
```

Validation runs in a preflight next to the existing canister checks, so
a misconfigured project fails in milliseconds instead of after `moc` has
run.

## `wasm-opt` failures now fail the build

Previously a `wasm-opt` crash warned and kept the unoptimized module,
exit 0. `[optimize]` describes the artifact you asked for, so silently
substituting a different one left no signal for anything downstream that
hashes, certifies or deploys it — and the warning scrolled past in CI
logs. `--no-optimize` is still the way to skip the pass deliberately,
and `--verbose` still prints full `wasm-opt` output.

## Builds are reproducible, and now tested

Adds a test asserting that two builds of the same fixture into separate
output directories produce byte-identical `.wasm`, `.did` and `.most`.

This was an open question — nothing previously established whether `moc`
plus mops' candid-metadata embedding was deterministic. It is, verified
before writing the test. Note the compiler version is recorded in a
`motoko:compiler` custom section, so changing `[toolchain] moc` changes
the artifact hash by design.

## Migration

Projects using `[optimize]`: pin Binaryen once and commit it.

```bash
mops toolchain use wasm-opt 131
```

Projects without `[optimize]` are unaffected. Anyone who previously
relied on the auto-pin already has the pin in `mops.toml` from the first
build that wrote it.

## What is unchanged

`mops toolchain use wasm-opt` with no version still resolves the latest
release — that command exists to write the manifest, and asking the
network is its job. This only removes the resolution from commands that
compile.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from kamil-v3/hermetic-builds to v3 August 11, 2026 12:04
@Kamirus
Kamirus dismissed automation-sa-sre’s stale review August 11, 2026 12:04

The base branch was changed.

@automation-sa-sre automation-sa-sre left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: the AI review verdict for 3c6696c is APPROVE. See the "Cursor AI review" comment for details.

@Kamirus
Kamirus changed the base branch from v3 to main August 19, 2026 18:00
@Kamirus
Kamirus dismissed automation-sa-sre’s stale review August 19, 2026 18:00

The base branch was changed.

Kamirus added a commit that referenced this pull request Aug 20, 2026
Folds the 3.x line into `main` ahead of the 3.0.0 GA. 68 commits, 291
files.

`main` becomes the 3.x development and release line; `v3` is deleted on
merge. `v2` stays as an archive of the 2.x line with no release path.

## Squash this

`main` is governed by ruleset `main` (id 20838766) with
`required_linear_history` and no bypass actors, so a merge commit is
rejected — `allowed_merge_methods: ["squash", "merge"]` is contradictory
config that fails at merge time. Every one of main's last 40 commits has
a single parent, so linear history here is deliberate and consistently
applied, not incidental.

**Squash and merge** is therefore the method. Before merging, tag the
branch so the 68 commits survive its deletion:

```bash
git tag v3-final origin/v3 && git push origin v3-final
```

Safe to push: `release.yml` triggers only on `cli-v*`, so `v3-final`
fires nothing. Without it the commits become unreachable from any branch
once `v3` is auto-deleted — recoverable only through `refs/pull/*/head`.

Two alternatives, both rejected:

- **Rebase-linearize onto `main`.** Would keep all 68 commits *and*
satisfy linear history, but replaying them individually conflicts (first
failure at `319951b3 chore(cli)!: require Node >= 20…`) even though the
merge itself is clean. Resolving conflicts across 62 replays to land a
promotion is not worth it.
- **Temporarily drop `required_linear_history`, merge, restore.** Works
and puts all 68 commits in main's log, but fights a policy the repo
applies uniformly. The tag gets the same preservation without touching
the ruleset.

## Still blocking

- **One approving review.** `required_approving_review_count: 1` with no
bypass actors, and the PR author cannot self-approve.
- **Status checks**: `ci-ok`, `ci-ok-cli`, `ci-ok-mops`.

## No conflicts, and no sync needed

Test-merged locally: clean, zero conflicts. The two `nanoid` GHSA bumps
that were only on `main`
([#769](#769),
[#770](#770)) merge without
intervention — `nanoid 3.3.18` is present in both `blog/` and `docs/`
lockfiles in the merged tree. An extra `main` → `v3` sync beforehand
would have added a merge commit for nothing.

I also checked that `v3` content wins everywhere it must: `lastVersion:
'current'` in the Docusaurus config, the `main`-only release guard, the
consolidated `## Next` with no `## 3.0.0 (unreleased)` heading, no
`cli/legacy-lock-flag.ts`, and the release post and 3.x `mops-verify`
page both present.

## What lands

Breaking changes, each with its migration in the changelog's **Migrating
from 2.x** list:

- `dfx` support removed outright — no `moc` fallback, no `--replica`, no
`toolchain init` / `moc-wrapper`, no `watch --deploy` / `--generate`,
`dfx` rejected in `[package]`
- Every toolchain tool must be pinned: `moc`, and `pocket-ic` for
replica tests / `bench` / `--check-deploy`, with no silent default.
`[optimize]` requires a `wasm-opt` pin and no longer rewrites
`mops.toml` from a build-time network lookup
- One lockfile model: `--locked` replaces `--lock
<check|update|ignore>`, plain installs self-heal, the `CI` env-var
auto-detection is gone
- Integrity verified at download time; `mops verify` is the on-disk
audit
- `.most` required for `check-stable` baselines
- Node.js >= 20; `set-network` / `get-network` and vessel migration
removed
- Stricter defaults: unknown flags before `--` rejected, `watch`
conservative, `test` verbose, `info --versions` newest-first

Plus the install-engine work — parallel downloads, batched registry hash
fetches, single-pass resolution, self-healing on transient network
failures — and a long tail of resolution, `sync`/`add`/`remove` and
cache fixes.

## After merging

1. Re-enable squash merging.
2. Rename `blog/blog/2026-08-19/` to the GA date — the post is dated by
directory and the release tag is what publishes it.
3. Run **Prepare CLI release** → `major`. `cli/package.json` is at
`2.24.0`, so this produces `3.0.0` and rolls the single `## Next` into
one `## 3.0.0`.
4. Merging the release PR pushes `cli-v3.0.0`, which publishes npm, the
GitHub Release, `cli.mops.one`, and the docs canister — the deploy that
makes 3.x the site root and moves 2.x to `/2.x`.
5. Repoint or drop the npm `next` dist-tag, which otherwise keeps
serving `3.0.0-beta.5`.
6. [#711](#711) currently shows
the whole `v3` delta because its head branches off `v3`; that collapses
back to its own three commits once this lands, with no rebase.

CI here runs against the full 68-commit delta, so it is a real signal
rather than a formality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Michael Morandi <michael.morandi@caffeine.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: caffeine-ci-bot[bot] <249119985+caffeine-ci-bot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants