Publishing to npm

Contents

This is the end-to-end pipeline: per-platform binaries published as scoped sub-packages, a single meta package users install, and trusted publishing so CI never needs an NPM_TOKEN.

How distribution works

Your addon ships as one main package plus one binding sub-package per platform:

my-addon                            <-- users install this
@my-addon/binding-darwin-arm64      <-- npm picks this on Apple Silicon
@my-addon/binding-darwin-x64        <-- npm picks this on Intel macs
@my-addon/binding-linux-x64-gnu     <-- ...
@my-addon/binding-linux-x64-musl
... (one per platform)

The main package's optionalDependencies lists every binding. npm uses each binding's os, cpu, and libc fields to install only the one that matches the user's machine. There is no postinstall hook and no native build step on the consumer's side. A user runs npm install my-addon and gets a single fast download.

The @my-addon part is your npm scope: the @something prefix on each binding's package name. It must refer to either:

  • Your npm username. Every npm account has a personal scope at @<your-username>. It exists automatically.
  • An organization you own on npm. Create one at npmjs.com/org/create before you publish.

The recommended pattern is to make the scope name match the package name (@my-addon for my-addon); that is also the default if you scaffolded with napikit new. Matching scope to package name makes binding ownership obvious to consumers. You can use any scope you own.

The scope is set in build.zig, in the .scope field inside the .npm block:

.npm = .{
    .scope = "@my-addon",
    // ...
},

If you change the scope later, the next napikit build --release will migrate npm/ cleanly. See Cross-compiling.

First-time setup, in order

Each step below assumes the previous one is done. Do them in this order.

1. Decide your scope

Open build.zig and review the .scope field. Set it to a username or org you own (or keep the default @<package-name> if you scaffolded). If the scope is an org, create that org now at npmjs.com/org/create. The recommended pattern is for the scope name to match the package name.

2. Update npm to a recent version

Trusted publishing requires npm >= 11.16.

npm install -g npm@latest

3. Cross-compile

napikit build --release

This produces the npm/ tree that gets published. Every per-platform binary is built. See Cross-compiling for what this lays out.

4. Log in to npm

npm login

The next step needs to publish initial 0.0.0 versions; that requires being logged in.

5. Push your code to GitHub

The publish workflow lives in .github/workflows/publish.yml. napikit new writes it for you; if you set up by hand, copy the YAML from The CI workflow below into that path. The workflow runs on tag push, so it must be on the default branch before you tag a release. If your repo is local-only, push it now:

git init
git add -A
git commit -m "initial commit"
git remote add origin git@github.com:<owner>/<name>.git
git branch -M main
git push -u origin main

6. One-time publish + OIDC trust

napikit npm-init --repo <owner>/<name> --workflow publish.yml

This:

  1. Verifies the scope exists and is accessible to the logged-in account. If not, it stops with a clear error and instructions.
  2. Publishes initial 0.0.0 versions of every package (main + every per-platform binding).
  3. Configures npm trusted publishing for each one, stamping <owner>/<name> and publish.yml into the trusted-publisher record.

--repo is owner/name of your GitHub repository. --workflow is the workflow filename in .github/workflows/.

napikit npm-init is idempotent: it skips the initial publish for packages that already exist, then checks their trusted-publishing records and fills in any missing GitHub Actions trust. A record counts as configured only when it matches the requested GitHub repository and workflow and grants publish permission. A mismatched or unverifiable record is retried, and npm conflicts remain failures so an unrelated trusted publisher cannot be mistaken for success. After the first successful run the CI pipeline takes over for releases. Re-run napikit npm-init if you later add another addon to build.zig, or if an existing package still needs its trusted publisher configured (see Multiple addons in one repo below).

Initialization validates the GitHub repository, workflow filename, npm package names and versions, and binding paths before using them. It reports publish and trusted-publisher failures per package, then exits non-zero if any required operation failed. A partial run must not be read as success; fix the reported package and re-run the same command to complete the idempotent setup.

The release loop

Once setup is done, every release is a single command:

napikit bump

napikit bump updates the version in every package.json (main + per-platform bindings + any extra packages), commits, creates an annotated git tag, and explicitly pushes the tracked branch plus that release tag in one round-trip. It does not push unrelated local tags. The tag push triggers the publish workflow on GitHub Actions, which runs napikit publish.

Without arguments, napikit bump shows an interactive picker (patch / minor / major / prerelease / conventional / explicit). You can also pass an explicit type or version:

napikit bump          # interactive picker
napikit bump patch    # 1.2.3 → 1.2.4
napikit bump minor    # 1.2.3 → 1.3.0
napikit bump major    # 1.2.3 → 2.0.0
napikit bump 1.5.0    # exact version

napikit bump requires:

  • A clean working tree, including no staged or untracked changes. This is checked before any package file changes.
  • The current branch tracks a remote when push is enabled, so the branch and current release tag have an explicit destination.
  • The target v<version> tag does not already exist when tagging is enabled.
  • The publish workflow already on the default branch.

The first three conditions are preflight checks. --no-push and --no-tag intentionally skip the corresponding upstream or tag check. The workflow-on-default-branch condition remains an operational prerequisite. napikit bump is what normally triggers the publish on the remote, so without a remote there is nothing to publish.

The CI workflow

Save this as .github/workflows/publish.yml (napikit new writes it for you). With trusted publishing configured (step 6 above), no secrets are needed.

name: Publish
on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: mlugg/setup-zig@v2
        with: { version: 0.16.0 }
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - name: Update npm
        run: npm install -g npm@latest
      - run: npm install
      - run: npx napikit build --release
      - run: npx napikit publish

Notes:

  • id-token: write is what enables OIDC. Don't remove it.
  • Zig 0.16.0 is the generated-project baseline. Node 24 satisfies the napikit CLI requirement of Node.js >=20.19.0.
  • The Update npm step is required: trusted publishing needs npm >= 11.16, and actions/setup-node ships an older default.
  • The job runs on a single ubuntu-latest runner because Zig cross-compiles every target from one host. No matrix needed.
  • This example is the npm variant. napikit new adapts setup/install/execute steps to the selected package manager and pins Bun 1.3.14 when Bun is selected.

This .github/workflows/publish.yml belongs to a generated addon repository. The @teakit/napi source repository itself releases the napikit CLI through .github/workflows/publish-to-npm.yml; that repository workflow depends on the shared five-command quality gate in .github/workflows/quality.yml.

What napikit publish does

For each package in npm/:

  1. Reads its package.json.
  2. Runs npm publish --access public.
  3. Attaches a provenance attestation (auto in CI).

Per-platform binding packages are published before the main package, so users who install during the small window between publishes always get a working set.

napikit publish runs over every addon in npm/, so a repo with multiple addLib calls in build.zig publishes them all in the same CI run.

The command keeps per-package diagnostics and attempts the ordered package set. Already-published versions are idempotent skips, but any other required pack or publish failure makes the command exit non-zero after reporting the failing package; it never prints a successful completion for a partial publish.

Extra packages in npm/

Anything you drop into npm/ that isn't generated by the napikit CLI (a pure-JS wrapper, a companion CLI, a types-only package) is treated as a first-class package. As long as a top-level directory under npm/ has a package.json with a name and no optionalDependencies, every release command picks it up automatically:

  • napikit bump bumps its version in lockstep with the generated packages (its own dependency ranges are left untouched, since those are yours to manage).
  • napikit publish packs and publishes it alongside the addon (after the main package, so an extra that depends on the addon sees it on the registry first).
  • napikit npm-init publishes its initial version and configures trusted publishing for it.
  • napikit build --release leaves it alone and does not flag it as an orphan.

Just create the folder and commit it:

npm/
  my-addon/                  <-- generated by the napikit CLI
    @my-addon/binding-*/
  my-addon-cli/              <-- your extra package, published as-is
    package.json
    index.js

There's nothing to configure. Drop the package in npm/ and it ships with the rest.

Multiple addons in one repo

You can ship more than one addon from the same repository: call addLib once per addon in build.zig (each with its own .name and .scope). Every command in this guide already iterates per-addon: napikit build --release cross-compiles every one, napikit bump bumps every one in lockstep, napikit publish publishes every one. See addLib reference for the constraints on .scope.

When you add a new addon to an existing repo:

napikit build --release
napikit npm-init --repo <owner>/<name> --workflow publish.yml

napikit npm-init skips the initial publish for packages that are already on npm, but still verifies that their trusted-publisher records match the requested repository and workflow. It publishes and configures trust for new packages, and repairs missing trust where possible; mismatched or unverifiable records remain explicit failures. From the next napikit bump onwards, the new addon ships alongside the existing ones.

Provenance

In CI, provenance is on by default. Override:

napikit publish --no-provenance     # opt out
napikit publish --provenance        # force on (rarely needed; default in CI)

Provenance proves the package was built from a specific commit on a specific workflow. It shows up on the npm registry as a verified badge.

Provenance requires every package to declare a repository field that points to the source tree, otherwise npm rejects the publish with "package must specify a repository". Set .repository once in build.zig (.npm.repository = "owner/repo"). Release builds then write the field into the main package and every per-platform binding (twelve files by default), so the package.json files stay in sync without hand-editing. See addLib reference for the accepted forms.

What users see

npm install my-addon
import addon from "my-addon";
addon.add(2, 3);

No postinstall hook, no node-gyp, no nan. npm picks the right binding via optionalDependencies and the platform fields, and the install is one fast download.

Troubleshooting

Scope @<name> not found or not accessible to <username> during napikit npm-init. The org doesn't exist yet, or you're not a member. Create it at npmjs.com/org/create, or change the scope in build.zig to one you already own and re-run napikit build --release before retrying.

napikit npm-init configured some packages but then failed. Read the per-package publish/trust errors, correct the first required failure, and run the same command again. Existing package versions and matching trusted-publisher records are skipped idempotently. If npm reports a conflict, inspect or revoke the mismatched trusted publisher before retrying; the failed run intentionally exits non-zero.

napikit bump fails while pushing the tracked branch and release tag. Either the branch has no upstream, or you don't have push access. Set the upstream once with git push -u origin main and confirm the remote is correct with git remote -v.

The published main package's optionalDependencies versions don't match the bindings. This shouldn't happen. napikit bump updates every package.json in lockstep, and napikit build --release reconciles optionalDependencies to the current main version. If you see drift, run napikit build --release once more to re-sync.

You changed .scope after the first napikit build --release. Run napikit build --release again. It removes the old <scope>/ directory, writes the new one, and updates the main package's optionalDependencies to match. The version (managed by napikit bump) and your user fields are preserved.

You changed .platforms (added or removed targets). Run napikit build --release again. Bindings for removed targets are deleted and optionalDependencies is updated; bindings for added targets are created.

You renamed the addon's .name. Run napikit build --release again. The build creates a new npm/<new-name>/ tree, then warns about the old npm/<old-name>/ orphan. Copy any user fields you want to keep onto the new main package.json, then delete the orphan folder. Renaming a published npm package itself is a separate concern: see npm's renaming guide.