ci: publish 3.x previews to npm and serve 3.x docs at /next - #692
Merged
Conversation
During the 3.x preview the released line is what visitors should land on. lastVersion: 'current' put the unreleased 3.x docs at the root, so anyone on 2.20 following a bare docs.mops.one link got documentation for a version they cannot install yet. Flip back to 'current' at the 3.0.0 GA.
Moves one production deploy off dfx as a low-ceremony exercise of icp-cli before trusting it more broadly. The cli (cli-releases) canister stays on dfx deliberately — one production path at a time. Asset sync reuses the certified-assets plugin already pinned in icp.yaml, so .ic-assets.json and .well-known/ic-domains are handled the same way the dfx asset canister handled them. docs.mops.one resolves only while that file is served, so it is asserted in the build output before anything is uploaded and over HTTP afterwards, alongside a probe of each version tree. --no-create means a failed canister link errors instead of quietly creating a second docs canister and populating it.
Lets 3.0.0-beta.N ship to npm from the v3 branch without disturbing anyone on 2.x. A separate workflow rather than conditionals in release.yml, so the stable path cannot be broken from here. --tag next is the load-bearing part: release.yml publishes with no tag, which npm resolves to latest, and a beta landing there would be served to every 'npm i -g ic-mops'. The dist-tags are read back after publishing and the job fails if latest moved off the 2.x line. The version comes from the tag rather than a committed bump, so v3 needs no churn commit per beta. No cli-releases canister upload — previews are npm-only, which leaves 'mops self update' on 2.x; the release notes say so.
npm allows exactly one trusted publisher per package and pins it to a single workflow filename. ic-mops spends that slot on release.yml, so an OIDC publish from release-preview.yml would be rejected, and taking the slot would break stable 2.x releases. A granular token scoped to ic-mops is the only option that leaves the stable path untouched. Provenance still works — --provenance needs id-token: write, not trusted publishing. The job fails early with an explicit message when the secret is missing.
The guard tested the whole release-notes file, which always contains the preamble, so it could never fire and an empty changelog section would have gone out silently.
The post-deploy probes assume 2.x at the root and 3.x at /next. Flag the coupling at both ends so the GA flip does not fail a release job on URLs that no longer exist.
Mirrors release.yml's auth shape exactly — id-token: write, the npmjs registry-url, and --provenance with no token env var — so the OIDC claim matches a trusted publisher entry configured for this workflow filename. No environment: key on the job on purpose: release.yml declares none, so its entry carries no environment, and adding one here would mismatch the claim and fail the publish with an opaque auth error.
cli-v* also matches cli-v3.0.0-beta.0, so a preview tag started the stable release workflow as well. It aborted on the 'not on main' check, but left a failed Release CLI run on every beta that reads like the release broke. The ancestor check stays as the backstop.
Kamirus
marked this pull request as ready for review
August 6, 2026 17:06
npm allows exactly one trusted publisher per package, pinned to a single workflow filename, so a separate release-preview.yml could never authenticate. One workflow is also the better factoring: a second copy of the build, docs deploy and release-notes steps would have drifted from the real one. cli-v* already matched cli-v3.0.0-beta.1, so a prerelease tag fired the full stable path today. That is the hazard this closes. Prerelease is detected by parsing the version with semver and asking for a prerelease component, not by matching the tag string, so -beta.N and -rc.N both work and a typo aborts as an invalid version instead of quietly taking the stable path. Stable tags must be on main, previews on v3; a prerelease tag on neither aborts. Previews publish with --tag next and assert afterwards that latest did not move, take their version from the tag, and skip the cli-releases upload, the artifacts PR and the app-repo pin bump. Docs still deploy. With every conditional false this is the stable path unchanged.
Both AGENTS.md and the mops-cli skill told contributors that docs/docs/ is served at the root of docs.mops.one. With 2.x at the root for the preview that now sends people to the wrong tree.
Contributor
Cursor AI review👀 HUMAN REVIEW REQUESTED — significant intended changes detected
Significant Changes Requiring Human Review
VerdictDecision: REQUEST_HUMAN_REVIEW Generated for commit 2af1c94 |
Review of the consolidated workflow found two failures that would have hit every release, both after npm publish and the GitHub Release had already run: - `icp canister link --force` unregisters the existing mapping first, and that write fails when `.icp/data/mappings/ic.ids.json` does not exist — which is every fresh checkout, since nothing under `.icp/` is tracked. Plain link first, `--force` only as the rerun fallback. - The post-deploy probes accepted any HTTP 200, but the asset canister answers unknown paths with the root index.html at 200. Every probe passed against the live site before any of this shipped, including a deliberately fake URL. Each path now asserts on body content, discriminating on Docusaurus's own `docs-version-*` class so it cannot be satisfied by the fallback. `probe()` also declared `mode="$3"` while both call sites passed two arguments, so under `set -u` the verification aborted on its first probe. Alongside those: the dist-tag guard asserted "latest is 2.x", which would fail every preview after the 3.0.0 GA — it now asserts the version-agnostic property, that latest is never a prerelease. Version extraction drops the unpinned, script-enabled `npm install semver@7` from the publishing job in favour of the official semver regex, which is also strict where `semver.valid()` was lax: `cli-vv3.0.0-beta.1` used to validate, publish, and only then fail the dist-tag comparison. `npm dist-tag ls` gets `|| true` so a registry hiccup cannot abort the step before its assertions run.
The action had grown to 169 lines replacing a 10-line dfx step, and the additions were where every bug in review came from — a probe that could not fail, an unbound variable, a guard testing the wrong file — while `icp deploy` itself was never at fault. Dropped the pre-build step: the docs recipe in icp.yaml already builds with `npm run build-docs` during deploy, so it built the site twice and checked the copy it then threw away. Dropped the four configurable page probes with them, along with the `verify-paths` input and its parsing. The dfx step they replaced verified nothing at all; what remains is the one check worth keeping, that `.well-known/ic-domains` still names the domain, since losing that file takes docs.mops.one offline.
Kamirus
added a commit
that referenced
this pull request
Aug 10, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tagging
cli-v3.0.0-beta.Nonv3publishes a preview to npm under thenextdist-tag and deploys the docs site.npm i -g ic-mopskeeps serving 2.x; preview users opt in withnpm i -g ic-mops@next.The preview path lives inside
release.ymlrather 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.
mainv3latestnextAfter publishing a preview the job asserts
latestis not a prerelease, so a mistake here cannot silently redirect everynpm i -g ic-mops.Worth knowing:
cli-v*already matchedcli-v3.0.0-beta.1before this change, so tagging a prerelease would have run the full stable path — publishing tolatestand bumping the production pin.Docs
lastVersionmoves to2.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-domainsstill names the domain. That file is served only becausedocs/static/.ic-assets.jsonopts dot-directories into the upload, and losing it takes the custom domain offline. Rollback is the previous command:Known interaction: one docs canister, two branches
Both
mainandv3deploy the docs canister, and they carry differentlastVersionvalues, 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/nextlinks 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
mainwould stop docs-only fixes there from ever publishing. Landing thislastVersiononmainwas also rejected:main'sdocs/docsis the 2.x content, so the flip would serve the frozen snapshot at the root and push main's live docs to/nextlabelled "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 importon a headless Linux runnericp canister linkagainst a runner with no ID storeicp deployto the docs canister, and whether its uploader honours.ic-assets.jsonfor.well-knownif:conditions, which were read off the parsed workflow but never triggeredVerified locally: the docs build with both URL trees present, prerelease detection across valid and malformed versions,
npm versionkeepingpackage-lock.jsonin sync, and icp-cli importing the existing dfx pem to the same principal — soMOPS_IDENTITY_PEMneeds no change.Follow-up
icp canister linkwrites.icp/data/mappings/<env>.ids.jsonand.gitignoreonly covers.icp/cache/, so local deploy state is committable. Pre-existing since #550.