Skip to content

ci: publish 3.x previews to npm and serve 3.x docs at /next - #692

Merged
Kamirus merged 12 commits into
v3from
kamil-v3/release-preview
Aug 7, 2026
Merged

ci: publish 3.x previews to npm and serve 3.x docs at /next#692
Kamirus merged 12 commits into
v3from
kamil-v3/release-preview

Conversation

@Kamirus

@Kamirus Kamirus commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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.

Kamirus added 8 commits August 6, 2026 18:56
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
Kamirus marked this pull request as ready for review August 6, 2026 17:06
Kamirus added 2 commits August 6, 2026 19:11
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.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Cursor AI review

👀 HUMAN REVIEW REQUESTED — significant intended changes detected

Category Assessment Details
Summary Adds a cli-v* prerelease path in release.yml (npm --tag next, skip cli-releases/artifacts) and moves production docs deploy from dfx to a new deploy-docs icp-cli action, with Docusaurus serving 2.x at / and 3.x at /next.
Code Quality Preview deviations are explicit if:/shell conditionals; docs deploy is a reusable composite action that reads canister_ids.json and retries deploy + domain checks without speculative helpers.
Consistency icp-cli@1.2.0 default matches .github/workflows/ci.yml; docs URL guidance updated in AGENTS.md and .agents/skills/mops-cli/SKILL.md to match docs/docusaurus.config.js.
Security Traced tag→semver prerelease detection, branch gate (main vs v3), --tag next publish, post-publish latest/next assertions (.github/workflows/release.yml), PEM import via umask 077 + RUNNER_TEMP in .github/actions/deploy-docs/action.yml; workflow permissions/tag trigger unchanged.
Tests ⚠️ No workflow/fixture coverage (expected for release.yml); first mainnet icp identity import / canister link / deploy / .ic-assets.json / dist-tag read-back remain unexercised until a real tag, as the PR notes.
Maintainability Composite action + comments document trusted-publisher constraint, stable-vs-preview splits, and docs rollback; agent docs stay aligned with the URL flip.

Significant Changes Requiring Human Review

  • S1: Prerelease npm publish path in release.yml

    • References: .github/workflows/release.yml lines 36–79, 96–103, 235–295, 348–392
    • Base behavior: Any cli-v* tag on main validated package.json, published to npm latest (default tag), uploaded cli-releases, and deployed the CLI canister.
    • Diff proof: Head parses semver prerelease, requires v3 for previews, runs npm version from the tag, publishes with --tag next, skips cli-releases/CLI canister/artifacts/app-bump, and fails the job if latest is the preview or itself a prerelease.
    • Impact: Confirm preview gating and dist-tag checks are acceptable before the first cli-v3.0.0-beta.* tag — a mistake here affects every npm i -g ic-mops user.
    • Confidence: High
  • S1: Production docs deploy moved to icp-cli and URL layout flipped

    • References: .github/actions/deploy-docs/action.yml lines 1–79; .github/workflows/release.yml lines 394–399; docs/docusaurus.config.js lines 49–57
    • Base behavior: Release deployed docs with dfx deploy --network ic … docs, and Docusaurus used lastVersion: 'current' (3.x at site root, 2.x under /2.x).
    • Diff proof: Head always deploys docs via icp identity import / canister link / icp deploy (both stable and preview), asserts docs.mops.one/.well-known/ic-domains, and sets lastVersion: '2.x' with current at path: 'next'.
    • Impact: Confirm the unproven GHA/mainnet icp-cli path and .ic-assets.json upload behavior, plus the intentional main vs v3 docs-layout race, before the first deploy that uses this action.
    • Confidence: High

Verdict

Decision: REQUEST_HUMAN_REVIEW
Risk: Medium-High
Reason: Intended release-pipeline and production-docs changes with large blast radius (npm dist-tags and docs.mops.one). Implementation looks careful and guarded, but a senior engineer should sign off before the first real tag.


Generated for commit 2af1c94

Kamirus added 2 commits August 6, 2026 19:37
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 Kamirus closed this Aug 7, 2026
@Kamirus Kamirus reopened this Aug 7, 2026
@Kamirus
Kamirus merged commit 66a58b0 into v3 Aug 7, 2026
23 checks passed
@Kamirus
Kamirus deleted the kamil-v3/release-preview branch August 7, 2026 13:08
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>
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