# 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**: from `package.json` fields to a published version on **npmjs.org with `restricted` access**, installable only by npm users with read rights to the `nurix` org. Naming a package and structuring its workspace are [`pnpm.md`](pnpm.md) §1–2; consuming a published package is `pnpm.md` §3. Reference implementations: `~/dev/components` (`@nurix/components`, library) and `~/dev/etna/packages/cli` (`@nurix/etna`, CLI).

npmjs is the registry for **both publish and install**. A caching proxy may serve reads, but it is **never a publish target** — a proxy URL in a publish path is a bug. Publish and install **must** name the same registry: a restricted read answers `404`, not `401`, so a package published to one registry and installed from another looks exactly like one never published (the consumer-side pairing check is `pnpm.md` §3).

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

1. Confirm you are a member of the npm org `nurix` with publish rights — <https://www.npmjs.com/settings/nurix/packages>.
2. Generate an npm **Automation** token with `Read and write` on the `nurix` scope (npm → Generate New Token → Automation).
3. Add it as the GitHub Actions secret `NPM_PUBLISH_TOKEN` on every publishing repo. Default to that name; use another only with a strong reason.

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

**Private by default.** A package is born `"private": true` and stays there until another repo needs it. Publishing creates a contract — once published, a removed export or a widened peer range breaks consumers you cannot see. A package used only inside its repo **never** publishes; it links with `workspace:*` (`pnpm.md` §2). A package crossing the boundary 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`** — default `"restricted"`: visible only to `nurix`-org readers. `"public"` is world-readable — use it only when that is the intent. A scoped package without the field defaults to `restricted`; set it explicitly anyway.
- **`files`** — without it, npm publishes everything not in `.gitignore`, **including source**. Library → `["dist"]`; CLI (etna) → `["bin/", "lib/", "data/"]`. Omit `package.json` and `README.md` — npm always includes them.
- **Build to `dist` — never ship raw `src`.** A published library compiles with `tsc` (`declaration` and `declarationMap` come from `tsconfig.base.json`) and points `exports`/`types` at `./dist/*`, `types` first in each subpath block (`pnpm.md` §2 owns the ordering). To keep the dev entry on source, swap source→dist at publish time with `publishConfig.exports`.
- **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` builds and verifies its types in `prepublishOnly`. 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` §2). `workspace:*` between 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
```

**Keep all three loop safeguards** — dropping any one lets the dual trigger publish in a loop or race: `paths:` scopes the push trigger to artifact-affecting files; `[skip ci]` in the bump commit stops that commit re-triggering the workflow; `cancel-in-progress: false` queues near-simultaneous merges instead of racing for a version number.

**`registry-url` is load-bearing** — without it on `setup-node`, no `.npmrc` line reads `$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 pushed bump commit cannot pick it up; a hand-rolled auth line goes to `$HOME/.npmrc`, **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"`), never a literal `${VAR}` left for npm to expand at read time — that breaks silently in any later step lacking 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); without `--no-git-checks`, pnpm refuses to publish from a non-release branch or from a tree carrying the bump commit the previous step made. `npm publish` is correct only for a single-package repo on an npm lockfile.

## 4. The three ways to ship

- **A. Auto on merge (default).** Land on the default branch; a change to a `paths:`-filtered file fires the workflow 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 first-publish bootstrap for a repo that ships only from CI. §7's checklist bootstraps from a local publish instead; both are sanctioned.
- **C. Local (CI down, one-off, pipeline bootstrap).** `npm version <patch|minor|major>` → publish → `git push --follow-tags`. Sanctioned, at the price of bypassing 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, §6); in a subdirectory of the repo root, `npm version` needs `--no-git-tag-version` plus a manual commit and tag.

Local auth uses two tokens in 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, **never** in `.npmrc` or the repo) for `npm publish`.

> **Agent guard — if `NPM_PUBLISH_TOKEN` isn't in the environment, never work around it.** No `npm login` swapping the user's auth, no rewriting `~/.npmrc`, no substituting the read token (it lacks publish rights → `403`), no `--registry` override, no temp-file token. **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."

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 act on the right `package.json`.
- **Scoped concurrency** — `group: publish-<pkg>` lets sibling packages publish in parallel.
- **Tag prefix only when more than one package cuts release tags** — `npm version --tag-version-prefix="<pkg>-v"` (→ `<pkg>-v0.2.0`) keeps their tags from colliding.
- **One workflow file per published package** — each `publish-<pkg>.yml` owns its `paths:` filter, `working-directory`, and concurrency group. One shared workflow couples every package's release cadence and republishes siblings on unrelated changes.
- **Publish dependencies first.** If `A` depends on `B` by `workspace:^`, `B` **must** already exist on the registry, or `A`'s rewritten range resolves to nothing for every consumer.

A repo with one publishable package needs only `working-directory`: plain `v0.2.0` tags, concurrency scoping optional.

## 6. Common pitfalls

| Symptom | Cause → fix |
| --- | --- |
| **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 or wrong, its user lacks `nurix` write access, or it is a read-only (not Automation) token |
| **"Cannot publish over previously published version x.y.z"** | a bump ran, the publish failed, and it 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, `bin` points outside `files`, or the script lacks its `#!/usr/bin/env node` shebang |
| **"Git working directory not clean" on `npm version`** | the checkout has untracked or modified files → build only into `.gitignore`d paths (`dist/`) |
| **Published tarball contains source** | `files` missing from `package.json` → add `["dist"]` and republish |
| **Auto-publish fires too often** | no `paths:` filter, or one 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]"` |

- **pnpm consumers fail with "external package declared a dependency using the catalog protocol"** — the package went out via `npm publish` from a pnpm workspace, which ships `catalog:`/`workspace:` specifiers verbatim. A workspace member **always** publishes via `pnpm publish`, locally as well as in CI. Deprecate the broken version and republish.
- **Tarball has no `dist/` though `prepublishOnly` ran a clean build** — `incremental: true` writes `tsconfig.tsbuildinfo` *next to `tsconfig.json`*, outside `dist/`; a stale one survives `rm -rf dist`, so `tsc` trusts it and emits nothing. The `clean` script **must** purge it: `rm -rf dist *.tsbuildinfo`. The parity harness catches this class before it ships.
- **A normal commit silently skips its own push** — GitHub scans the head commit's **whole message**, subject _and_ body, for skip directives (`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, `***NO_CI***`); a hand-written commit that merely _mentions_ one skips the entire push, and the publish never fires (symptom: branch moved, paths matched, Actions on — zero runs for the SHA). **Never** write a literal skip token in a commit message or PR body unless you mean it; to ship a push that carried one 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`](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 — or, for a repo that ships only from CI, bootstrap with `bump = none` (§4B)
- [ ] README notes the `@nurix` scope so consumers know they need org access
