# Publishing `@nurix/*` packages

Load when: creating or publishing a `@nurix/*` package, wiring or debugging a publish workflow, or diagnosing a failed `npm publish`.

How a `@nurix/*` package **ships**: the npm pipeline from `package.json` fields to a published restricted-access version. Naming a package and structuring the workspace it lives in are [`pnpm.md`](../../../rules/pnpm.md) §1–2; consuming an already-published package is `pnpm.md` §3.

`@nurix/*` packages publish to **npmjs.org with `restricted` access** — only npm users with read rights to the `nurix` org can install them. Reference implementations: `~/dev/components` (`@nurix/components`, library) and `~/dev/etna/packages/cli` (`@nurix/etna`, CLI).

npmjs is the source of truth for **both publish and install**. A caching proxy may sit in front of it for reads, but it is **never a publish target** — treat a proxy URL in a publish path as a bug. And publish and install must name the **same** registry: a package published to one and installed from another 404s, and that 404 is indistinguishable from "never published", because a restricted read answers 404 rather than 401 so it doesn't leak which packages exist. (The consumer-side pairing check is [`pnpm.md`](../../../rules/pnpm.md) §3.)

## 1. Prerequisites (one-time, org-wide)

1. **npm org `nurix`** exists and you're a member with publish rights — <https://www.npmjs.com/settings/nurix/packages>.
2. **An npm automation token** with `Read and write` for the `nurix` scope — npm → Generate New Token → Automation.
3. **The token added as a GitHub Actions secret** named `NPM_PUBLISH_TOKEN` on every publishing repo. `NPM_PUBLISH_TOKEN` is the convention; pick another name only with a strong reason.

## 2. `package.json` — required fields

**Private by default.** A package is born `"private": true` and stays there until someone needs it from *another repo*. Publishing is a deliberate act that creates a contract — once published, an export removed or a peer range widened is a breaking change for consumers you cannot see. Internal-to-the-repo packages never publish; they link with `workspace:*` ([`pnpm.md`](../../../rules/pnpm.md) §2). When a package does cross the boundary, the manifest carries:

```jsonc
{
  "name": "@nurix/<package-name>", // The scope makes it discoverable to org members.
  "version": "0.1.0",              // Start at 0.1.0; the workflow bumps from here.
  "private": false,                // MUST be false (or omitted). `npm publish` refuses if true.
  "publishConfig": { "access": "restricted" }, // Scoped + private: only org members can install.
  "files": ["dist"]                // Whitelist of what ships. Anything outside is excluded.
}
```

- **`publishConfig.access`** — `"restricted"` (recommended): visible only to `nurix`-org readers. `"public"`: world-readable (free, but only if you mean it). Omitting it on a scoped package defaults to `restricted`; be explicit anyway.
- **`files`** — without it, npm publishes everything not in `.gitignore`, **including source**. Library → `["dist"]`; CLI (etna) → `["bin/", "data/", "README.md"]`. Don't list `package.json`/`README.md` — npm always includes them.
- **Build to `dist` — never ship raw `src`.** A published library compiles with `tsc` (`declaration: true` + `declarationMap: true` come from `tsconfig.base.json`) and points `exports`/`types` at `./dist/*` — `types` first in each subpath block ([`pnpm.md`](../../../rules/pnpm.md) §2 owns the ordering rule). To keep the dev entry on source, use `publishConfig.exports` to swap source→dist at publish time.
- **Build hooks** — `prepack` for build steps that must produce files in `files` (also runs on install-as-git-dep); `prepublishOnly` for publish-only steps. Either alone is fine; `@nurix/components` runs both. With the build hooked here, a publish can never ship a stale `dist`.
- **Host singletons are `peerDependencies`** — `react`, `react-dom`, the framework client — so consumers keep exactly one copy; **never** bundle them. Apps depend on them directly.
- **Dependencies carry registry specs only** — **no** `file:`/`link:` (the workspace-root boundary, [`pnpm.md`](../../../rules/pnpm.md) §2). `workspace:*` between workspace members is fine: `pnpm publish` rewrites it into a real range.
- **Other useful fields** — `type: "module"`, `engines.node`, `bin` (CLIs), `main`/`module`/`types`/`exports` (libraries), `sideEffects: ["**/*.css"]`, `repository` (with `directory` for monorepos), `license: "UNLICENSED"`.

## 3. The publish workflow (generic template)

The dual-trigger pattern: a push to the default branch auto-ships a patch; a manual dispatch picks minor/major (or `none` — §4B).

```yaml
name: Publish to npm
on:
  push:
    branches: [dev]               # your default branch
    paths:                        # ONLY paths that change what ships — else every README edit publishes
      - 'src/**'
      - 'scripts/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/publish.yml'
  workflow_dispatch:
    inputs:
      bump: { description: 'Version bump', type: choice, options: [patch, minor, major, none], default: patch }
permissions: { contents: write }  # push the version-bump commit + tag
concurrency: { group: publish, cancel-in-progress: false }  # monorepo: group: publish-<pkg>
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with: { fetch-depth: 0, token: '${{ secrets.GITHUB_TOKEN }}' }
      - uses: actions/setup-node@v5
        with: { node-version: 22, registry-url: 'https://registry.npmjs.org' }  # writes .npmrc using $NODE_AUTH_TOKEN
      - run: npm ci                 # skip ONLY if the build is pure-Node with no deps (e.g. @nurix/etna)
      - run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
      - run: npm version ${{ inputs.bump || 'patch' }} -m "chore: release v%s [skip ci]"
        if: ${{ inputs.bump != 'none' }}  # `none` skips the bump and ships the version as-is (§4B)
      - run: npm publish            # prepublishOnly / prepack runs the build
        env: { NODE_AUTH_TOKEN: '${{ secrets.NPM_PUBLISH_TOKEN }}' }
      - run: git push --follow-tags # pushes the bump commit AND the v<version> tag npm created
```

**Why dual-trigger is safe** — three safeguards prevent an infinite publish loop: (1) `paths:` scopes the push trigger to artifact-affecting files; (2) `[skip ci]` in the bump commit makes GitHub skip the workflow that bump would otherwise re-trigger; (3) `concurrency: cancel-in-progress: false` queues near-simultaneous merges instead of racing for a version number.

**`registry-url` is load-bearing** — without `registry-url: https://registry.npmjs.org` on `setup-node`, it never writes the `.npmrc` line that uses `$NODE_AUTH_TOKEN`, and the publish falls back to anonymous auth and fails.

**The token never enters the repo checkout** — `setup-node` writes its auth `.npmrc` outside the workspace, so the bump commit the job pushes cannot pick it up; a hand-rolled auth line goes to `$HOME/.npmrc` for the same reason, never a repo-path `.npmrc`. Expand the token in the shell step that writes it (`echo "//registry.npmjs.org/:_authToken=$NPM_PUBLISH_TOKEN" >> "$HOME/.npmrc"`) rather than leaving a literal `${VAR}` for the npm client to expand at read time — read-time expansion silently breaks in any later step that lacks the variable.

**A pnpm workspace publishes with `pnpm publish --no-git-checks`** — swap it into the publish step above (and into flow C, §4). Only pnpm rewrites `catalog:`/`workspace:` specifiers into real ranges (§6); `--no-git-checks` is required because pnpm otherwise refuses to publish from a non-release branch or from a tree carrying the bump commit the previous step just made. `npm publish` stays correct only for a single-package repo on an npm lockfile.

## 4. The three ways to ship

- **A. Auto on merge (common).** Land on the default branch; if a `paths:`-filtered file changed, the workflow fires and ships a patch.
- **B. Manual dispatch (minor/major/none).** **Actions → Publish to npm → Run workflow**, pick `bump = minor|major` — or `none`, which publishes the current version as-is: the CI-side bootstrap for a first publish when a repo ships exclusively from CI (§7's checklist bootstraps from a local publish instead; both paths are sanctioned).
- **C. Local (CI down / one-off / pipeline bootstrap):** `npm version <patch|minor|major>` → publish → `git push --follow-tags` — sanctioned, at the cost that it bypasses CI's build, lockfile, and version-bump discipline. In a pnpm workspace the publish command is **`pnpm publish --no-git-checks`** (never `npm publish` — the catalog-protocol pitfall in §6; the flag rationale is §3); in a subdirectory of the repo root, `npm version` needs `--no-git-tag-version` plus a manual commit + tag.

Local auth uses two tokens, two homes: a **read** token in `~/.npmrc` (`//registry.npmjs.org/:_authToken=…`) for `npm install`, and `NPM_PUBLISH_TOKEN` as an env var (sourced from `~/.zsh_secrets` or equivalent, **not** in `.npmrc` or the repo) for `npm publish`.

> **Agent guard — if `NPM_PUBLISH_TOKEN` isn't in the environment, do not work around it.** No `npm login` to swap the user's auth, no rewriting `~/.npmrc`, no substituting the read token (it lacks publish rights → `403`), no `--registry` overrides, no temp-file token. Instead **stop and report**: "This repo can't publish locally — `NPM_PUBLISH_TOKEN` isn't set. Source `~/.zsh_secrets` (or wherever you keep it) and re-run, or trigger the CI workflow." Some users publish exclusively from CI — that's the default path for a reason.

Verify after any flow: `https://www.npmjs.com/package/@nurix/<name>` and a clean-dir `npx @nurix/<name>@latest`.

## 5. Monorepo deviations

When the package lives in `packages/<pkg>/` of a multi-package repo:

- **`working-directory: packages/<pkg>`** on the bump and publish steps so they operate on the right `package.json`.
- **Scoped concurrency** — `group: publish-<pkg>` lets sibling packages publish in parallel.
- **Tag prefix — only with multiple tagging packages.** `npm version --tag-version-prefix="<pkg>-v"` (→ `<pkg>-v0.2.0`) is worth it only when more than one package cuts release tags, so they don't collide. A single publishable package (etna's case, even though it's laid out as a monorepo) uses plain `v0.2.0`.
- **One workflow file per published package** — each `publish-<pkg>.yml` owns its `paths:` filter, `working-directory`, and concurrency group. A single shared workflow couples every package's release cadence and republishes siblings on unrelated changes.
- **Publish dependencies first.** If package `A` depends on `B` by `workspace:^`, `B` must already exist on the registry — otherwise `A`'s rewritten range resolves to nothing for every consumer.

A repo with one publishable package only needs `working-directory`; skip the prefix, and concurrency scoping is optional.

## 6. Common pitfalls

- **402 Payment Required** — `access: "public"` on an org without a plan for free public scoped packages. Use `"restricted"` (or upgrade the plan).
- **403 Forbidden** — `NPM_PUBLISH_TOKEN` missing/wrong, or its user lacks `nurix` write access, or it's a read-only (not Automation) token.
- **"Cannot publish over previously published version x.y.z"** — a bump ran but publish failed and was re-run without rebasing. `git reset` the bump and let CI re-bump, or `npm version patch && git push --follow-tags && npm publish`.
- **Publish succeeds but `npx @nurix/foo` is "command not found"** — no `bin` field, or `bin` points outside `files`, or the script is missing its `#!/usr/bin/env node` shebang.
- **"Git working directory not clean" on `npm version`** — the checkout has untracked/modified files; build only into `.gitignore`d paths (the components repo builds into `dist/`).
- **Published tarball contains source** — missing `files` in `package.json`. Add `["dist"]` and republish.
- **pnpm consumers fail with "external package declared a dependency using the catalog protocol"** — the package was published with `npm publish` from a pnpm workspace: only **`pnpm publish`** rewrites `catalog:`/`workspace:` specifiers to real versions; npm ships them verbatim. A workspace member must always publish via `pnpm publish` (locally too, not just CI). Deprecate the broken version and republish.
- **Tarball has no `dist/` even though `prepublishOnly` ran clean + build** — a stale `tsconfig.tsbuildinfo` (which `incremental: true` writes *next to tsconfig.json*, outside `dist/`) survived `rm -rf dist`, so `tsc` trusted it and silently emitted nothing. The `clean` script must purge it: `rm -rf dist *.tsbuildinfo`. The parity harness catches this class before it ships.
- **Auto-publish fires too often** — no `paths:` filter or one that's too broad. Tighten it to artifact-affecting files, or drop the push trigger for `workflow_dispatch`-only.
- **Workflow loops forever** — the bump commit lacks `[skip ci]`. Invoke `npm version` with `-m "chore: release v%s [skip ci]"`.
- **A normal commit silently skips its own push** — the inverse trap. GitHub scans the head commit's **whole message** (subject _and_ body) for skip directives (`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, `***NO_CI***`). If any hand-written commit merely _mentions_ the literal token in prose, the entire push is skipped and the publish never fires (symptom: branch moved, paths matched, Actions on — yet zero runs for the SHA). Never write the literal token in a commit message or PR body unless you mean it; to ship a push that carried it by accident, re-trigger via **Run workflow** / `gh workflow run … --ref dev`.

## 7. Quick checklist for a new `@nurix/*` package

- [ ] `package.json`: `name: "@nurix/X"`, `version: "0.1.0"`, `private: false`, `publishConfig.access: "restricted"`, `files: [...]`
- [ ] Build step wired into `prepack` (or `prepublishOnly`)
- [ ] Name follows [`pnpm.md`](../../../rules/pnpm.md) §1; if it's a workspace member, boundaries follow `pnpm.md` §2
- [ ] `.github/workflows/publish.yml` from §3
- [ ] `NPM_PUBLISH_TOKEN` secret added to the repo
- [ ] First publish from local (flow C, §4) to verify the pipeline end-to-end, then a no-op commit to verify CI — a repo that ships exclusively from CI bootstraps with `bump = none` instead (§4B)
- [ ] README notes the `@nurix` scope so consumers know they need org access
