Skip to content

Set up secure npm publishing for CineplanetCLI #7

Description

@cuevaio

Publishing CineplanetCLI to npm

This runbook explains the one-time setup and the recurring release process for
publishing the Rust CLI to the public npm registry. It is written for the
maintainer who owns both the npm package and the GitHub repository.

Status: npm publishing is not configured in this repository yet. Follow the
one-time setup before attempting a release. Do not run npm publish from the
repository root: this is a Rust project and currently has no npm package to
publish.

Release model

Use dist to build native Rust
binaries, upload them to a GitHub Release, generate a small npm installer
package, and publish that installer to npm.

The npm package does not compile Rust on the user's machine. At installation
time it downloads the correct prebuilt binary from the matching GitHub Release
and exposes it as the cineplanet-cli command. This supports both:

npm install --global cineplanet-cli
cineplanet-cli

and:

npx cineplanet-cli

The Cargo package version, Git tag, GitHub Release, and npm package version must
always match. For example, version 0.2.0 uses tag v0.2.0 and npm version
0.2.0.

This differs from the crafter.run release flow. Crafter publishes JavaScript
built from its monorepo and uses Changesets to create a release PR. This project
publishes generated npm installer metadata plus native binaries, so dist and
version tags are the source of the release workflow. The useful practices kept
from Crafter are CI-only publishing, explicit verification, provenance, release
serialization, and a documented recovery process.

One-time npm account setup

1. Create and verify the account

  1. Open https://www.npmjs.com/signup.
  2. Choose a lowercase username, provide an email address, and use a unique
    password stored in a password manager.
  3. Accept npm's terms and create the account.
  4. Open npm's verification email and verify the email address. npm does not
    allow an unverified account to publish.
  5. Install a current Node.js release and confirm npm login locally:
npm login
npm whoami

npm whoami must print the expected maintainer account. Never place the npm
password in GitHub Actions.

2. Enable two-factor authentication

Publishing requires strong authentication. In npmjs.com, open the profile menu,
select Account, and enable Two-Factor Authentication. Prefer a WebAuthn
security key such as Touch ID or a hardware key.

Store the recovery codes in a password manager, separately from the security
key. Add a second security key when possible, and link the maintainer's GitHub
account under npm's recovery options.

3. Choose the package owner and name

There are two reasonable naming models:

  • cineplanet-cli: unscoped, public, and simplest for npx cineplanet-cli.
  • @OWNER/cineplanet-cli: public package owned by an npm user or organization.
    Users run npx @OWNER/cineplanet-cli.

An npm organization is optional for public packages. Use one when multiple
maintainers need durable team ownership instead of tying releases to one
person. Create it from Profile > Add an Organization, select the free public
package plan, and invite at least one backup maintainer. After the first publish,
grant and verify package access as well as organization membership. For an
unscoped package:

npm owner add BACKUP_NPM_USERNAME cineplanet-cli
npm owner ls cineplanet-cli

For a scoped organization package, grant an npm team read/write access to the
package and verify it in the package's Access settings.

Check the proposed name immediately before configuration:

npm view cineplanet-cli name version

An E404 currently indicates that the name is not published, but availability
can change and does not establish trademark permission. The maintainer must
confirm that the project name and package metadata comply with npm policy and
any applicable Cineplanet trademark requirements before publishing.

One-time repository setup

The following work should be implemented and reviewed in a normal pull request.

1. Complete the Cargo package metadata

Add at least these fields under [package] in Cargo.toml:

[package]
name = "cineplanet-cli"
version = "0.1.0"
description = "Find Cineplanet showtimes with good contiguous seats"
repository = "https://github.com/asther0/cineplanet-cli"
homepage = "https://github.com/asther0/cineplanet-cli"
readme = "README.md"
license = "LICENSE-IDENTIFIER"

Replace LICENSE-IDENTIFIER only after the project has an actual license file
and the owner has selected its SPDX identifier. Do not invent a license during
release setup.

2. Install and initialize dist

Install dist using its current official installation instructions, then run:

dist init

During setup:

  • Enable GitHub Releases.
  • Enable the npm installer.
  • Select the npm scope only if using a scoped package.
  • Select the initially supported targets. Do not advertise an operating system
    until its binary has been exercised there.
  • Enable the npm publish job.
  • Enable GitHub artifact attestations (github-attestations = true) so the
    native archives receive attestations separate from the npm installer.

The resulting dist configuration should include the npm installer and publish
job. Depending on the installed dist version, it may appear in Cargo.toml or
dist-workspace.toml and resemble:

[workspace.metadata.dist]
installers = ["npm"]
publish-jobs = ["npm"]
# npm-scope = "@OWNER" # Only for a scoped package.

dist init also generates .github/workflows/release.yml. Treat that file as
generated: change dist settings and rerun dist init instead of hand-editing
the workflow unless the configuration explicitly opts into custom CI.

Validate the generated release plan:

dist plan
dist build
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings

Inspect the generated npm package and confirm it exposes exactly one command,
cineplanet-cli, and points to release artifacts from
asther0/cineplanet-cli.

3. Review the generated GitHub workflow

As of August 2026, released dist 0.32.0 does not generate an npm trusted
publishing job. Its built-in npm publisher uses Node 20 and NPM_TOKEN; upstream
OIDC support is still pending. Do not assume that adding id-token: write to the
generated workflow is sufficient.

The supported initial implementation for this repository is therefore:

  1. Use dist's generated npm publish job.
  2. Store a short-lived, granular NPM_TOKEN as a repository secret.
  3. Migrate to OIDC only when the installed dist release supports it, or replace
    the built-in publisher with a reviewed custom publish workflow.

The release workflow must:

  • Trigger releases only for version tags such as v0.2.0. Use GitHub's Rerun
    failed jobs
    operation for recovery; dist's dispatch mode is an alternative
    release mode, not an additional trigger.
  • Grant only the permissions each job requires.
  • Configure https://registry.npmjs.org as the npm registry.
  • Build all advertised target archives before publishing.
  • Depend on required branch CI for formatting, tests, and Clippy. The generated
    dist workflow builds release binaries but does not replace those checks.
  • Use immutable full commit SHAs for release actions where practical. dist can
    manage these through github-action-commits; mutable major-version action
    tags carry additional supply-chain risk.

The built-in publish job will consume the repository secret as
NODE_AUTH_TOKEN or NPM_TOKEN. Confirm this in the generated file after every
dist upgrade. Do not hand-edit generated CI: dist rejects drift unless
allow-dirty = ["ci"] is deliberately enabled, which also makes future dist
upgrades manual.

When dist gains trusted publishing support, or a custom reusable publish job is
adopted, the publish job needs the equivalent of:

permissions:
  contents: read
  id-token: write

steps:
  - uses: actions/checkout@v6
  - uses: actions/setup-node@v6
    with:
      node-version: "24"
      registry-url: https://registry.npmjs.org
      package-manager-cache: false
  - run: npm publish --access public

Node must be at least 22.14.0 and npm at least 11.5.1 for npm OIDC. The
complete dist workflow contains additional planning, native build, artifact
upload, and GitHub Release jobs. Do not replace those jobs with this fragment.

If the publish step lives in a reusable workflow, npm may validate the calling
workflow rather than the called workflow. Configure the exact filename npm
observes, and grant id-token: write in both caller and called workflows.

4. Decide whether to add a protected GitHub environment

The current built-in dist npm job does not declare a GitHub environment. Its
NPM_TOKEN must therefore be a repository secret, not an environment secret.

If the project later adopts a custom publish job, create an environment named
npm under Repository Settings > Environments. Add required reviewers and
prevent self-review if the repository's GitHub plan supports those controls.
Set that custom npm publish job to:

environment: npm

Using an environment is recommended because a tag alone should not silently
publish a permanent registry version. This requires a custom dist publish job
or deliberately maintained custom CI; adding the environment only in GitHub's
settings does not affect the built-in job. If the environment name is included
in npm's trusted publisher configuration, the workflow must use the exact same
case-sensitive name.

Bootstrap the first npm version

npm can attach a trusted publisher only after the package exists. The first
version is therefore a controlled release using the temporary granular token.

  1. Finish and merge the dist setup PR.
  2. Confirm the intended package name is still available.
  3. Create the temporary token and repository secret described in
    Configure the npm token.
  4. Before tagging, run dist build and find the generated npm package path in
    its output. Inspect that exact package rather than running npm commands from
    the Rust repository root. For a generated .tar.gz package:
tar -tzf target/distrib/GENERATED-NPM-PACKAGE.tar.gz
tar -xOf target/distrib/GENERATED-NPM-PACKAGE.tar.gz package/package.json
  1. Push the first release tag and let the reviewed dist workflow publish it.
  2. Verify the first version as described in Verify a release.
  3. Restrict the token to only the new package if All Packages was required
    for bootstrap, or replace it with a package-scoped token.

Do not run npm publish from this repository root. A manual emergency publish
must target the exact generated tarball, for example
npm publish target/distrib/GENERATED-NPM-PACKAGE.tar.gz --access public, and
should not be used for a normal release.

Never publish an empty placeholder package merely to reserve the name. The
first published package must install and run.

Configure trusted publishing with GitHub OIDC

Trusted publishing is the security target because GitHub issues a short-lived
identity token for one workflow run and no reusable npm credential is stored in
GitHub. It is not supported by dist's built-in npm job in released dist 0.32.0.
Follow this section only after upgrading to a dist version that explicitly
supports npm OIDC, or after implementing and reviewing a custom publish job.

npm configuration

  1. Open the package on npmjs.com.
  2. Open Settings > Trusted Publisher.
  3. Select GitHub Actions.
  4. Set Organization or user to asther0.
  5. Set Repository to cineplanet-cli.
  6. Set Workflow filename to the generated release workflow filename, for
    example release.yml. Enter the filename only, not .github/workflows/.
  7. Set Environment name to npm if the publish job uses that environment.
  8. Allow npm publish. For stricter human approval, allow only
    npm stage publish and adopt npm staged publishing instead.
  9. Save and re-check every case-sensitive value. npm does not validate this
    configuration until a publish is attempted.

Each npm package can have only one trusted publisher configuration.

GitHub configuration

The publish job must have:

permissions:
  contents: read
  id-token: write

Do not set NPM_TOKEN or NODE_AUTH_TOKEN on the OIDC publish step. npm
automatically detects GitHub OIDC and exchanges it for a short-lived publish
credential. npm whoami cannot test OIDC because the exchange occurs only for
npm publish or npm stage publish.

After the first successful OIDC release:

  1. Open Package Settings > Publishing access on npm.
  2. Select Require two-factor authentication and disallow tokens.
  3. Revoke any old write tokens.

This setting blocks traditional token publishing but continues to allow the
configured trusted publisher.

For a public npm package built from this public GitHub repository, trusted
publishing automatically includes npm provenance. It attests the small npm
installer package and links it to its source commit and workflow. It does not by
itself attest the native Rust archive downloaded later from GitHub Releases.
Keep dist's GitHub artifact attestations enabled for those archives and verify a
downloaded archive with gh attestation verify PATH --repo asther0/cineplanet-cli.

Configure the npm token (current dist path)

This is the working authentication path for dist 0.32.0. Keep the token
short-lived and migrate to OIDC when the installed dist release supports it.
npm supports granular access tokens; legacy tokens have been removed.

Create the token

  1. Sign in to npmjs.com.
  2. Open Profile > Access Tokens > Generate New Token.
  3. Name it cineplanet-cli GitHub Actions and document why it exists.
  4. Enable Bypass two-factor authentication. Non-interactive direct
    publishing cannot answer a 2FA prompt.
  5. Set Packages and scopes to Read and write.
  6. Select only cineplanet-cli after the package exists. For the first publish,
    npm may require All Packages because the package cannot yet be selected.
  7. Set Organizations to No access. Organization administration access
    does not grant package publishing access.
  8. Do not set an IP allowlist for GitHub-hosted runners because their outbound
    addresses are not stable enough for a practical narrow allowlist.
  9. Choose the shortest expiration that covers the bootstrap or recovery window.
  10. Generate and copy the token immediately. npm will not display it again.

Store the token in GitHub

The built-in dist publisher requires a repository secret:

gh secret set NPM_TOKEN --repo asther0/cineplanet-cli

Paste the token when prompted. Do not place it on the command line, in a file,
in .npmrc, or in shell history. Confirm only the secret name is visible:

gh secret list --repo asther0/cineplanet-cli

The equivalent web path is Repository Settings > Secrets and variables >
Actions > New repository secret
. Name it NPM_TOKEN. If a future custom
publish job declares environment: npm, move the token to that environment
instead.

Use the token in the workflow

actions/setup-node writes a temporary runner .npmrc that reads
NODE_AUTH_TOKEN. Expose the GitHub secret only to the publish step:

- name: Publish npm installer
  run: npm publish --access public
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Some release tools expect NPM_TOKEN directly. If dist requires it, pass both
names from the same secret only to that job:

env:
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Never print either environment variable. GitHub masking reduces accidental
exposure but does not make logging a secret safe. Token authentication does not
automatically produce npm provenance. Adding --provenance also requires an
OIDC-capable GitHub-hosted job with id-token: write; do not add the flag to the
generated dist job without implementing and reviewing that complete setup.

Rotate or revoke the token

Tokens expire and are tied to the creating user's package permissions. Before
expiration, create a replacement with the same restricted permissions, replace
the GitHub secret, verify one publish, and revoke the old token under npm
Access Tokens. If exposure is suspected, revoke first and investigate
before releasing again.

After OIDC succeeds, remove the GitHub NPM_TOKEN secret and revoke the token.

Normal release process

Do not manually publish normal releases. The maintainer prepares a version; the
tag causes GitHub Actions to build and publish that exact source revision.

1. Select the version

Use the smallest SemVer increment that describes the user-visible change:

  • patch: compatible bug fix or internal improvement.
  • minor: backward-compatible capability or command behavior.
  • major: breaking CLI, output, configuration, installation, or platform
    support change.

Before 1.0.0, document how the project interprets breaking 0.x releases and
apply that rule consistently.

Do not publish prerelease versions such as 0.2.0-beta.1 until a separate
prerelease policy is implemented and tested. A prerelease must use a non-stable
npm dist-tag such as next or beta; it must never move latest. dist's
publish-prereleases behavior and generated npm command must be reviewed before
enabling it.

2. Prepare and test the release PR

  1. Update version in Cargo.toml.
  2. Run Cargo once so Cargo.lock records the same package version.
  3. Update release notes or a changelog if the project adopts one.
  4. Run:
cargo fmt --check
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
dist plan
  1. Confirm the dist plan includes the intended npm package, target binaries,
    checksums, and GitHub Release.
  2. Merge the reviewed PR to main.

3. Create and push the release tag

Start from the exact merged commit on main:

git switch main
git pull --ff-only origin main
git status --short
grep '^version' Cargo.toml | head -1
git tag -a v0.2.0 -m "Release v0.2.0"
git push origin v0.2.0

Replace 0.2.0 everywhere with the prepared version. Before pushing, confirm
the working tree is clean and the tag version equals Cargo.toml. A published
npm version is immutable, so do not reuse a tag or version.

Wait for the release workflow to finish before pushing another release tag.
The generated dist workflow does not serialize separate tag runs by default.

4. Supervise GitHub Actions

gh run list --workflow release.yml --limit 5
gh run watch

If a custom publisher uses the protected npm environment, approve its
deployment after checking the version, source commit, planned artifacts, and
completed tests. The built-in dist publisher has no environment approval. In
either case, confirm that npm publishing starts only after all required binary
builds succeed.

Verify a release

Do not treat a green workflow as sufficient. Verify the registry and a real
installation:

npm view cineplanet-cli version dist-tags repository --json
npm view cineplanet-cli@0.2.0 dist.integrity dist.tarball --json
npx --yes cineplanet-cli@0.2.0

Also verify:

  • latest points to the intended stable version.
  • The npm page links to https://github.com/asther0/cineplanet-cli.
  • If OIDC trusted publishing is configured, npm displays provenance and links
    it to the correct workflow run and commit.
  • The corresponding GitHub Release exists and contains all expected archives
    and checksums.
  • Each native archive has a GitHub attestation that verifies with
    gh attestation verify.
  • A clean global install exposes the expected executable:
npm install --global cineplanet-cli@0.2.0
command -v cineplanet-cli
cineplanet-cli
npm uninstall --global cineplanet-cli

Test at least every supported OS/architecture before calling a release fully
verified. The TUI is interactive, so verification must confirm that it starts,
renders, accepts input, and exits cleanly, not merely that installation exits
with status zero.

Failed releases and recovery

First determine which irreversible steps completed:

npm view cineplanet-cli@0.2.0 version --json
gh release view v0.2.0
gh run list --workflow release.yml --limit 5

Nothing was published

Fix the workflow or code in a normal PR. If the tagged source itself is valid
and the failure was transient, rerun the failed workflow. Do not move the tag to
a different commit.

GitHub Release exists but npm does not

Fix authentication or the npm publish job, then rerun only if the generated npm
package still references the same immutable tag and artifacts.

npm already contains the version

Do not retry, overwrite, or reuse that version; npm versions are immutable. If
the package is correct but later automation failed, repair the GitHub metadata
without republishing npm. If the package is defective, prepare a new patch
version.

For a harmful release, deprecate the bad version with a clear replacement:

npm deprecate cineplanet-cli@0.2.0 "Broken release; use 0.2.1"

Use npm unpublish only for a security incident or other exceptional case and
only after reviewing npm's current unpublish policy. Deletion can break users
and does not permit casually reusing package names or versions.

Authentication errors

For ENEEDAUTH or OIDC failures, check:

  • The npm trusted publisher owner, repository, workflow filename, and
    environment exactly match GitHub, including case and .yml/.yaml.
  • The job has id-token: write.
  • The publisher is running on a GitHub-hosted runner.
  • Node is at least 22.14.0 and npm is at least 11.5.1.
  • repository metadata points to the same GitHub repository.
  • A reusable workflow has OIDC permission in both caller and called workflow.
  • No stale NODE_AUTH_TOKEN is forcing an unintended token fallback.

For token publishing, check the token has not expired, has read/write package
access, has bypass 2FA enabled, and belongs to a user who still has permission
to publish the package.

Installation errors

If npm publishes successfully but installation fails, inspect the generated
package, GitHub archive names, checksums, target mapping, and release visibility.
The npm installer fetches GitHub Release artifacts, so deleting or renaming
those artifacts breaks installation of an otherwise valid npm version.

Maintainer checklist

One-time setup:

  • npm account created and email verified.
  • WebAuthn 2FA enabled; recovery codes and backup key stored safely.
  • Package owner, name, and trademark position confirmed.
  • Backup maintainer or npm organization ownership configured.
  • Cargo package metadata and license completed.
  • dist configured for reviewed native targets and the npm installer.
  • Generated release workflow reviewed and tested with dist plan.
  • Short-lived, package-scoped NPM_TOKEN stored as a repository secret.
  • First working package version bootstrapped.
  • Backup owner or team can administer and publish the package.
  • OIDC migration tracked for a supporting dist release or custom publisher.

Every release:

  • Version updated consistently in Cargo.toml and Cargo.lock.
  • Formatting, tests, Clippy, and dist plan pass.
  • Release PR reviewed and merged to main.
  • Annotated vX.Y.Z tag points to the merged release commit.
  • No other release-tag workflow is still running.
  • npm version, latest, GitHub Release, attestations, and clean install verified.
  • npm provenance verified when trusted publishing is enabled.
  • Supported platform smoke tests completed.

References

Review npm and dist documentation when implementing the workflow or rotating
credentials. Registry security requirements and generated workflow details can
change independently of this repository.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions