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. Use the maintained npm version

Trusted publishing requires npm >= 11.16.

npm install -g npm@11.18.0 --registry https://registry.npmjs.org/

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 --registry https://registry.npmjs.org/

The next step publishes missing packages at the repository's current common version (0.0.0 for a new repository); 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. Run git init only when this directory is not already in a Git worktree, and add origin only when that remote is not already configured:

git init
git add -A
git commit -m "chore: initialize addon"
git remote add origin https://github.com/OWNER/REPO.git
git push -u origin HEAD

6. One-time publish + OIDC trust

napikit npm-init --repo OWNER/REPO --workflow publish.yml

This:

  1. Verifies the required npm version and one common package version before any npm registry operation.
  2. Verifies the scope exists and is accessible to the logged-in account. If not, it stops with a clear error and instructions.
  3. Publishes every missing package (main + every per-platform binding + extras) at the repository's current common version, which starts at 0.0.0 in a new repository.
  4. Configures npm trusted publishing for each one, stamping OWNER/REPO 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, binding paths, and every generated or extra package's repository before any npm operation. Each repository must resolve to the requested --repo; npm string forms and standard { type, url, directory? } metadata are accepted when they identify one canonical GitHub repository and use a safe relative directory. Missing or mismatched metadata fails before scope or registry access. Every initial package is packed into an isolated directory and receives the same manifest, payload, tar, repository, and immutable-copy attestation as a normal release, so a lifecycle script cannot substitute the package being published. The command 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 first requires one common version across every publishable package.json (main + per-platform bindings + any extra packages). It captures the current symbolic branch and HEAD, updates every manifest in lockstep, commits, creates an annotated git tag, and atomically pushes the tracked branch plus that release tag in one round-trip. The branch and HEAD are re-attested across preflight, mutation, commit, and push boundaries. It does not push unrelated local tags. With --no-tag, the one branch ref is pushed without unnecessarily requiring remote atomic-push support. The tag push triggers the publish workflow on GitHub Actions, which runs napikit publish.

Package-manifest writes are transactional: every write revalidates the real file identity, mode, and bytes, and rollback removes or restores only manifest, temporary, recovery, or index entries still proven to belong to this transaction. Concurrent edits, replacements, partial writes, and restaging are preserved and reported. The command validates each literal manifest path, mode, and staged blob, then constructs the release commit with a detached HEAD and isolated Git index. Concurrently staged files stay in the user's real index and cannot enter the release commit. After commit hooks complete, napikit attests the exact parent, changed paths, manifest modes and bytes, and worktree copies before it atomically advances the captured branch with a compare-and-swap ref update. Hook-created commits that fail attestation never update a branch or tag; hook changes to the worktree are preserved for inspection. The command then reattests the branch and exact HEAD before it creates a tag or pushes. Ignored or assume-unchanged manifests fail safely. A failed git commit conditionally restores an index entry only while its mode and object ID still match the release, so a concurrent restage is preserved. If tag creation fails after a verified commit, or the atomic push is rejected or loses its connection, napikit keeps the local release commit instead of rewriting Git history. After fixing the cause, retry the exact version (for example, napikit bump 1.2.4, or add --no-push to keep a local-only release local). The retry proves that HEAD is exactly a version-only manifest rewrite of its single parent, creates a missing tag when needed, requires existing release tags to be annotated, verifies them against HEAD, confirms the branch is not behind when pushing, and then pushes the captured commit and annotated-tag object IDs rather than mutable local refs.

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 next     # stable: next patch; prerelease: next prerelease
napikit bump prepatch --preid rc  # 1.2.3 → 1.2.4-rc.1
napikit bump prerelease --preid rc # increment an existing prerelease
napikit bump 1.5.0    # exact version

The supported release types are major, minor, patch, premajor, preminor, prepatch, and prerelease, plus the next convenience mode and an exact semantic version. Any other positional value is an error; only the no-argument form opens the interactive picker. --preid accepts one valid semantic-version prerelease identifier (for example, beta or rc); invalid values fail before package or Git writes.

napikit bump requires:

  • A clean working tree, including no staged or untracked changes. This is checked before any package file changes.
  • A symbolic branch checkout. Detached HEAD is rejected for normal bumps and exact-version resumes, including with --no-push.
  • The current branch tracks a remote when push is enabled and is not behind or diverged from its freshly fetched upstream.
  • The target v<version> tag does not already exist locally or on that remote when tagging is enabled.
  • The publish workflow already on the default branch.

The first four conditions are preflight checks. --no-push skips the upstream and remote checks but not the symbolic-branch requirement; --no-tag skips the corresponding 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*"]

jobs:
  publish:
    permissions:
      contents: read
      id-token: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
        with: { version: 0.16.0 }
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org/
      - name: Update npm
        run: npm install -g npm@11.18.0 --registry https://registry.npmjs.org/
      - run: npm ci
      - run: npx napikit build
      - run: npm run test
      - 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 installs the exact maintained npm 11.18.0 baseline; trusted publishing requires npm >=11.16.
  • The host build and assertion test run before cross-compilation or registry access.
  • 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 records and pins npm 11.18.0, pnpm 10.34.5, Yarn 4.17.1, or Bun 1.3.14, then uses npm ci, pnpm/Bun --frozen-lockfile, or Yarn --immutable as appropriate.
  • Every third-party action is pinned to a full commit SHA. The readable version comment is informational; the SHA is the executed identity.

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. Before dependency installation or any OIDC-authorized publish, it verifies both the exact v<napikit-version> tag and that the checked-out release commit is reachable from origin/master.

What napikit publish does

Before processing any package, the command rejects version drift, captures every source manifest and generated payload, and, in GitHub Actions, requires GITHUB_REF_TYPE=tag, GITHUB_REF_NAME=v<common-version>, and repository metadata that resolves exactly to GITHUB_REPOSITORY. For each package in npm/:

  1. Validates the source policy. Generated manifests must remain byte-for-byte equivalent as JSON values, their main/binding relationship and npmjs/public metadata must be canonical, and managed runtime, declaration, and native files are captured as stable regular files. Extra packages retain their user-controlled fields while keeping a stable identity and package kind.
  2. Packs into an isolated temporary directory, leaving every existing .tgz untouched.
  3. Parses the bounded gzip/tar itself, verifying checksums, canonical package/ paths, duplicate entries, the packed manifest, and exact SHA-256/size equality for every managed generated payload.
  4. Copies the verified bytes to a private no-overwrite artifact and re-attests that artifact immediately before and after npm publish --access public against https://registry.npmjs.org/.
  5. Attaches a provenance attestation (auto in CI) only after the source and packed repository metadata resolve to the same expected GitHub repository.

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.

When Yarn 3 or 4 reports that a generated nested package is not a declared workspace, napikit retries from an isolated staging copy with its own lockfile. Any copied lockfile is replaced only inside that disposable staging project; the package's source lockfile and root lockfile are never modified. Pack output must be one regular .tgz file before npm receives it.

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. A publish conflict is an idempotent skip only after an exact name@version lookup succeeds on the canonical npmjs registry. 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 real top-level directory under npm/ has a valid package.json with a valid name, a strict semantic version, and no optionalDependencies, every release command picks it up automatically. Malformed manifests and symbolic-link package entries fail the release instead of being silently skipped:

  • 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/REPO --workflow publish.yml

The release build seeds the new addon's main and binding manifests with the one strict version already shared by every existing generated, binding, and extra package. A new repository starts at 0.0.0; an existing repository with invalid or drifting versions fails before Zig rather than assigning an arbitrary version. 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 at that common version, 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 resolves to the source tree. napikit checks this before packing and again inside the attested artifact; on GitHub Actions the normalized slug must equal GITHUB_REPOSITORY. npm repository strings such as github:owner/repo, supported GitHub Git URLs, and standard { "type": "git", "url": "...", "directory": "safe/relative/path" } objects are accepted. Credentials, ports, query strings, fragments, encoded paths, unsafe directories, and non-GitHub hosts are rejected. 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. Extra packages must declare matching metadata themselves. See addLib reference for the accepted generated-package 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. The branch may have no upstream, be behind or diverged from its remote, have a conflicting remote release tag, or lack push access. Set the upstream once with git push -u origin main, synchronize the branch, and confirm the remote is correct with git remote -v. Branch and tag updates are atomic: a rejected ref prevents both remote updates.

napikit publish rejects the GitHub release ref. Every publishable package must have the same version, and the workflow event must be a tag named exactly v<version>. A branch with that name is not a release tag. Do not retag a different commit; correct the package versions with the normal bump flow and create the matching release tag.

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 build-owned binding directories, removes the old <scope>/ only when it becomes empty, writes the new bindings, and updates the main package's optionalDependencies to match. Other files or directories under the old scope are preserved. 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.