# Publishing — stable releases and npm previews How cezar reaches npm. Two paths, deliberately separate (spec: `.ai/specs/2026-07-18-npm-preview-publish.md`, issue #482): - **Stable releases** (`latest`) are **owner-driven and manual**: a maintainer runs the [`Release`](../.github/workflows/release.yml) workflow from the Actions tab (`workflow_dispatch`) and picks the version bump. CI never moves `latest` — a push to `main` publishes nothing. - **Previews** are **CI-driven**: the `publish-snapshot` job in [`ci.yml`](../.github/workflows/ci.yml) publishes a snapshot of both packages after a fully green `verify` run — on `develop` pushes and same-repo PRs only. ## Stable releases Run **Actions → Release → Run workflow** from `main` and choose a bump: | Bump | Effect (base `0.1.5`) | |---|---| | `patch` | `0.1.6` | | `minor` | `0.2.0` | | `major` | `1.0.0` | | `existing` | publishes the version already committed to `package.json` | The workflow verifies, builds, then `scripts/release.mjs` stamps both manifests (the alias keeps a **caret** range on the scoped package — stable follows compatible releases, unlike the exact-pinned snapshots), publishes both with `--tag latest --provenance`, commits the bump, tags `v`, and cuts a GitHub Release. It's gated behind the `production` environment, so a release can require reviewer approval. Without `NPM_TOKEN` it degrades to a loud dry run. ## Preview channels | Event | Version (example) | dist-tag | Install | |---|---|---|---| | same-repo PR, CI green | `0.1.5-pr482.123` | `pr-482` | `npx cezar-cli@0.1.5-pr482.123` | | push to `develop` | `0.1.5-develop.124` | `develop` | `npx cezar-cli@develop` | | push to `main` | `0.1.5-main.125` | `main` | `npx cezar-cli@main` | Version scheme: `-.`, with `.` appended on re-runs so no publish ever collides. Prerelease versions under explicit dist-tags are invisible to a plain `npx cezar-cli`, which keeps resolving `latest`. Both packages publish in lockstep — the scoped implementation package first, then the unscoped `cezar-cli` alias with its dependency **pinned to the exact snapshot version** — so a preview always runs exactly the code it was built from. Names are read from the checked-out manifests at publish time, never hardcoded. On every PR snapshot the job upserts one sticky comment (marker ``) with the exact copy-pasteable commands. When a PR closes, [`npm-preview-cleanup.yml`](../.github/workflows/npm-preview-cleanup.yml) best-effort removes its `pr-` dist-tag from both packages (the versions themselves stay — npm allows unpublish only within 72 hours, and untagged prereleases are inert). ## Pieces | Piece | Role | |---|---| | `src/release/snapshot.ts` | pure decisions: channel/version/dist-tag, manifest stamping, install lines (unit-tested) | | `scripts/release-snapshot.mjs` | orchestrator: stamps manifests, `npm publish --tag --provenance`, emits result JSON (`--dry-run` supported; e2e-tested) | | `ci.yml` → `publish-snapshot` | gate (`needs: verify`), same-repo guard, provenance permissions, sticky PR comment, step summary | | `npm-preview-cleanup.yml` | dist-tag removal on PR close | Guards: the job runs only for pushes and same-repo PRs (fork PRs get no secrets, and `computeSnapshot` re-checks the head repo as defense in depth); the dist-tag is always explicit so a snapshot can never become `latest`; concurrency is non-cancellable so a publish never stops halfway between the two packages. **Without the `NPM_TOKEN` secret the job degrades to a loud dry run and stays green** — the pipeline is safe to merge before the admin setup below is done. ## One-time admin setup On **npmjs.com** (as an owner of the npm org and of the `cezar-cli` package): 1. Verify the org exists and your user is an **Owner**. 2. Give the org control of the unscoped alias (unscoped packages attach to orgs via teams) — run as the current `cezar-cli` owner: `npm access grant read-write :developers cezar-cli`. 3. Create a **granular access token**: *Read and write*; packages and scopes = the org scope (allow publishing new packages in it — the first scoped publish comes from CI) **plus** the `cezar-cli` package; set an expiry per your policy (CI fails loudly with `E401`/`E404` when it lapses). 4. For both packages: Settings → *Publishing access* → **"Require two-factor authentication or an automation or granular access token"** (CI publishes with the token; humans still need 2FA). On **GitHub** (this repository): 5. Settings → Secrets and variables → Actions → new repository secret **`NPM_TOKEN`** with the token from step 3. 6. Nothing else — the workflows declare their own `permissions:` blocks, so repo-level Actions defaults can stay read-only. After the first stable publish under a new package name (post-rename only): 7. Publish a final forwarding patch of the old scoped package and deprecate it: `npm deprecate "moved to — npx cezar-cli still works"`. ## Verifying a preview - The PR's sticky comment (or the job's step summary for branch pushes) has the exact command — e.g. `npx cezar-cli@0.1.5-pr482.123`. - `npm view cezar-cli dist-tags` shows every active channel. - Server flows accept pinned previews too: `npx cezar-cli@ server-deploy --platform ` (see [Remote access](server-install/README.md)).