Skip to content

ci: move the remaining canister deploys off dfx - #693

Merged
Kamirus merged 15 commits into
v3from
kamil-v3/deploys-on-icp
Aug 10, 2026
Merged

ci: move the remaining canister deploys off dfx#693
Kamirus merged 15 commits into
v3from
kamil-v3/deploys-on-icp

Conversation

@Kamirus

@Kamirus Kamirus commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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:

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,
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, 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 usersmops 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:

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.

The docs deploy moved to icp-cli in #692 and the cli canister was left on
dfx so only one production path changed at a time. Move it across too, and
generalise the composite action rather than keeping a second copy of the
identity import, ID link and retry logic.

Both canisters get the same baseline check — the custom domain still
resolves — and the cli deploy adds a version-specific one: /tags/latest
must name the new release and both tarballs must actually unpack, which is
what `mops self update` and the mops-test.yml matrix consume.

The identity import is now idempotent. `icp identity import` has no
--force and errors on an existing name, so the second canister through the
action would have failed on the identity the first one imported.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Cursor AI review

👀 HUMAN REVIEW REQUESTED — significant intended changes detected

Category Assessment Details
Summary Finishes the dfx→icp deploy migration: deletes dfx.json/canister_ids.json, commits .icp/data/mappings/, parameterizes deploy-canister, and requires MOPS_FRONTEND_NETWORK for frontend builds.
Code Quality Reuses one composite action instead of duplicating docs/cli deploy; closes staging→prod and silent-local-id footguns without speculative helpers.
Consistency package.json scripts, icp.yaml envs, AGENTS.md/DEVELOPMENT.md/NEXT-MAJOR.md, and frontend-testing skill all describe the same deploy model.
Security Traced PEM import (umask 077, RUNNER_TEMP, single job-level import), --no-create on mainnet, no new secrets/trigger broadening; staging env limited to main+assets.
Tests ci.yml builds frontend with MOPS_FRONTEND_NETWORK=local after deploy-local; release.yml keeps domain checks and adds CLI tarball/tags/latest verification; mainnet path remains release-only by nature.
Maintainability Single deploy config (icp.yaml), committed ID mappings, pinned cli assetstorage sha256, explicit dfx-version where dfx remains as a test replica.

Significant Changes Requiring Human Review

  • S1: Production release/deploy path now fully on icp-cli with committed canister mappings
    • References: .github/workflows/release.yml (approx. 365–440), .github/actions/deploy-canister/action.yml, icp.yaml (environments), .icp/data/mappings/ic.ids.json, .icp/data/mappings/staging.ids.json, package.json (deploy/deploy-ic/deploy-staging), frontend/vite.config.ts
    • Base behavior: release.yml deployed cli via dfx and docs via deploy-docs (link IDs from canister_ids.json); staging/ic deploys used DFX_NETWORK + dfx
    • Diff proof: deletes dfx deploy config/IDs, commits icp mappings (IDs match prior canister_ids.json for kept canisters), routes both canisters through icp deploy --no-create, and makes frontend network selection mandatory via MOPS_FRONTEND_NETWORK
    • Impact: next cli-v* release will upgrade live cli.mops.one / docs.mops.one through this unproven-on-mainnet path; confirm mapping completeness, identity import, and rollback procedure before merge
    • Confidence: High

Verdict

Decision: REQUEST_HUMAN_REVIEW
Risk: Medium-High
Reason: Intended, carefully gated migration of the production asset-canister release path and ID store; no probable bugs found, but AGENTS.md-class release-pipeline blast radius needs a human sign-off before the next cli-v* tag exercises it.


Generated for commit 02d9650

Kamirus and others added 14 commits August 7, 2026 15:32
`npm run deploy-staging` / `deploy-ic` were the last dfx callers. icp-cli
has no way to declare a canister ID in icp.yaml, so scripts/link-canister-ids.mjs
points its ID store at canister_ids.json first; the release action uses the
same helper, so CI takes a path a maintainer can exercise locally. `deploy`
now also passes --no-create: without a linked ID, dfx would have read
canister_ids.json but icp-cli would happily create a brand new canister.

Both mainnet environments are declared explicitly in icp.yaml. That keeps
`bench` off mainnet, which dfx.json did with a `remote` id, and stops a bare
deploy-staging from reaching docs/blog/cli — canister_ids.json gives those
the same id on both networks, so a staging deploy of them was a production
deploy under another name.

dfx.json's last reader was frontend/vite.config.ts, for a canisters/* alias
map pointing into a .dfx/ directory that no longer exists and that nothing
imported. With it gone the frontend selects its network with ICP_ENVIRONMENT
instead of DFX_NETWORK, and bakes the resolved value rather than the raw env
var so the network flag cannot disagree with the ids next to it.

deploy:main is dropped — it was deploy-local with an extra prompt.
dfx is gone from every deploy path, so DEVELOPMENT.md gets the commands
that replace it and the rollback sequence, and AGENTS.md's "dfx version is
pinned in dfx.json" rule is replaced by how canister IDs are linked.

setup-dfx stays in mops-test.yml and setup-mops.yml, contrary to what the
titles of those jobs suggest: mops 1.x and 2.x run test/storage-actor.test.mo
on the dfx *replica*, because mops.toml pins no [toolchain] pocket-ic and
those versions fall back to dfx. Pinning one would not rescue 1.0.0, which
speaks only the PocketIC 4.0.0 API. Both now pass dfx-version explicitly —
setup-dfx's `auto` read the version out of dfx.json, so with that file
deleted it would have silently started installing the latest dfx. In
mops-test.yml the install is skipped for the current CLI, which must not
need dfx and now fails rather than borrowing one.
Resolve canister_ids.json from the script rather than the cwd, and pass the
canister name to it through the environment like the action's other steps.
Review of the deploy rewrite found three gaps, none of which could target the
wrong canister but all of which fail quietly.

`npm run deploy` had picked up `-y`, which icp documents as skipping the Candid
interface compatibility check. The dfx script it replaced passed no such flag,
so upgrading the production registry canister with a narrowed or removed public
method would have gone through unchallenged — on the one path where a human is
present and no workflow runs. Dropped there, kept for deploy-local and CI.

The CLI serve check was the only network assertion in the release without a
retry, and it sits between the canister upload and the artifacts commit. A
boundary-node blip would have left the canister ahead of the repo, with the next
release generating releases.json from a stale base and a re-run republishing to
npm. It retries like everything around it.

The frontend build still emitted a bundle when canister ids could not be read:
the catch logged and left the map empty, the defines were never emitted, and a
catch-all `process.env` turned every lookup into undefined. Green build, SPA
that reaches nothing. Non-local now throws, and verify-build asserts an id is
actually baked into dist/bundle.

Also: import the release identity unconditionally rather than probing for one by
name, since a probe cannot tell whether it holds this key; take the domain check
off a pipeline that can SIGPIPE curl; surface the cause when listing canisters
fails; and drop a stale dfx.json mention from mops.toml.
icp-cli splits its store by network type: managed networks (local) go to
.icp/cache/, connected ones (ic, staging) to .icp/data/. The migration guide is
explicit that .icp/data/ "should be committed to version control. Only
.icp/cache/ should be gitignored" — the mainnet mappings are project state, not
machine state.

This repo ignored .icp/ wholesale and rebuilt the mapping on every deploy from
canister_ids.json, which is the runtime reconstruction of a file that is meant
to be in git. Committing .icp/data/mappings/{ic,staging}.ids.json deletes the
script, a CI step, two npm-script prefixes and the class of failure where the
relink is what breaks.

Verified against production without linking anything: in a checkout whose only
source of IDs is the committed mapping, `icp canister status docs -e ic`
resolves to iwejr-6iaaa-aaaal-qcema-cai and reaches mainnet, failing only on
the anonymous caller not being allowed to read status.

canister_ids.json stays: it also records IDs for canisters icp.yaml does not
manage, and the frontend build reads it.
icp-cli keeps mainnet ids in .icp/data/mappings/, which is committed, so
canister_ids.json was a second source of truth for the same values. The
frontend build now reads the icp mappings for every network instead of
its own two-branch split.

Three ids it held — dao-frontend, dao-backend, play-backend — have no
source in this repo and nothing deploys them; they are dropped rather
than carried, recoverable from git history if they ever matter.

Also fixes the frontend build assertion: it matched any principal-shaped
string, and cli/api/network.ts hardcodes the ic and staging endpoint ids
into the bundle, so it passed even on a build with no mappings at all.
It now asserts the specific main id read from the mappings file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The build guard was still a tautology on ic and staging: cli/api/network.ts
hardcodes both endpoint ids, and those are the same principals the mappings
give for `main`, so asserting the id is present proved nothing there. It now
also rejects any unreplaced CANISTER_ID_* define, which is what a missed
vite key actually leaves behind.

ICP_ENVIRONMENT loses its silent `local` default. `icp deploy assets -e ic`
run by hand sets nothing, so the old default baked local replica ids into a
mainnet bundle without a word. Verified icp-cli forwards the variable into
build steps, so deploy-ic is unaffected; svelte-check and the dev server pass
it explicitly.

Also pins the pre-built asset canister wasm by sha256 — everything else in
icp.yaml is pinned, and this entry now runs on the mainnet cli deploy — and
adds the missing prerelease guard on the docs deploy, which the comment above
it already claimed was stable-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requiring the variable broke CI's own Build frontend step, which set
nothing — it would have failed every run, ten minutes in, after the
replica and deploy steps. The local mappings already exist by then, so
the step passes `local` explicitly. Same for the two documented build
commands in AGENTS.md and the frontend-testing skill.

The unreplaced-define check now derives its keys the way vite.config.ts
derives them instead of pattern-matching: a loose /CANISTER_ID/ also hits
MOPS_REGISTRY_CANISTER_ID, which cli/api/network.ts reads at runtime on
purpose and which has to survive into the bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reusing icp-cli's ICP_ENVIRONMENT looked tidy but the two resolve
differently: `icp deploy -e ic` targets ic, while an exported
ICP_ENVIRONMENT=local still reaches vite. Local dev commonly exports it,
so `icp deploy assets -e ic` could bake local replica ids into a mainnet
bundle — silently, since verify-build.mjs reads the same variable and
would confirm the wrong answer. `npm run deploy` now derives
MOPS_FRONTEND_NETWORK from the value it passes to `-e`, so they cannot
disagree, and a raw `icp deploy` leaves it unset and fails.

Reverts the prerelease guard on the docs deploy. The preview notes point
3.x users at docs.mops.one/next/, which only exists if docs deploy on
preview tags; "everything from here to the docs deploy" meant up to, not
including. Comment reworded to say so.

Also: verify-build.mjs anchors its define-key match (MOPS_REGISTRY_CANISTER_ID
contains REGISTRY_CANISTER_ID, so renaming a canister to `registry` would
have false-positived) and drops its dependency on the minifier's quote
style; `npm run replica` clears stale local mappings, which otherwise
survived a replica reset and let a build bake a dead canister id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run deploy-local` sets no frontend network and deploys only `main`,
so it never reaches vite — a developer following that hint got the same
error again. Both messages now name the build command verbatim.

Also runs `npm run check` in code-quality: the frontend check script now
depends on an env-var default, and only the husky hook was exercising it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The identity import lived inside deploy-canister, so it ran once per
canister — which is why it needed the delete-then-import dance: `icp
identity import` has no --force, and the second canister through the
action would have failed on the identity the first one left behind.

Both deploys run in the same job on the same runner, so one import
serves both. icp-cli install moves up with it, since the import needs
the binary. Neither is gated on prerelease — the docs deploy runs on
preview tags.

MOPS_IDENTITY_PEM now crosses exactly one step rather than being passed
into the action twice, and the action drops to the three things that are
actually per-canister: npm ci, deploy, verify the domain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Kamirus
Kamirus merged commit 587f4a0 into v3 Aug 10, 2026
25 checks passed
@Kamirus
Kamirus deleted the kamil-v3/deploys-on-icp branch August 10, 2026 10:06
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.

1 participant