relnote docs · GitHub Action · CHANGELOG.md · Python packages · Rust crates · standard-version alternative · semantic-release alternative

Release an npm package from conventional commits

Bump version in package.json, bring package-lock.json along, prepend a CHANGELOG.md section, and tag in one local command. Pushing the tag runs tests, publishes to the npm registry with trusted publishing, and creates a GitHub Release with grouped notes. No semantic-release, no release bot, no config file.

1. The version lives in package.json

{
  "name": "demo",
  "version": "1.2.0",
  "description": "A small example package",
  "main": "index.js",
  "scripts": {
    "test": "node --test"
  },
  "license": "MIT",
  "files": [
    "index.js"
  ],
  "engines": {
    "node": ">=18"
  }
}

Commit package-lock.json (npm's default for apps and most libraries). It also records your package's version under packages[""].version, which is why the release step below updates it.

2. Cut the release locally

Three commits since v1.2.0:

git log --oneline v1.2.0..HEAD
docs: document the --json flag
fix: handle empty input without panicking
feat(cli): add --json output

Install relnote (pipx install git+https://github.com/loki-inu/relnote.git, brew install loki-inu/tap/relnote, or the single file), then run the release script pinned to a version:

curl -fsSLO https://raw.githubusercontent.com/loki-inu/relnote/v0.3.7/examples/release.sh
bash release.sh --bump-file package.json --exec "npm install --package-lock-only --ignore-scripts --silent"
next: v1.3.0
changelog: CHANGELOG.md
bump: package.json -> 1.3.0
exec: npm install --package-lock-only --ignore-scripts --silent
committed: chore(release): v1.3.0
tagged: v1.3.0
next step: git push --follow-tags

A feat made it a minor bump. --exec runs after the bump and before the commit; npm install --package-lock-only --ignore-scripts --silent rewrites only package-lock.json (no node_modules, no lifecycle scripts), and release.sh commits it with the rest:

git show --name-only --format= v1.3.0
CHANGELOG.md
package-lock.json
package.json

Without it, package-lock.json still says 1.2.0 while package.json says 1.3.0, so the next npm install dirties the tree with a lockfile-only change. The new CHANGELOG.md section:

## [v1.3.0] - 2026-11-03

### Features

- **cli:** add --json output (`4e39256`) — Ada Lovelace

### Fixes

- handle empty input without panicking (`a8cc6d8`) — Ada Lovelace

### Other

- **docs:** document the --json flag (`83256ad`) — Ada Lovelace

**Full changelog:** https://github.com/you/demo/compare/v1.2.0...v1.3.0

Output from a test repository built in relnote's CI with real npm (tests/test_npm_package_docs.py); your hashes and date differ. Use --dry-run first to see the version and notes without changing anything. Then:

git push --follow-tags

3. Publish and release on the tag

Save as .github/workflows/release.yml. Publish the first version by hand (npm publish with an access token); then, on npmjs.com, add a trusted publisher for this repository with workflow release.yml and environment release, and create that environment in the repository settings. Trusted publishing needs npm CLI 11.5.1+ and Node 22.14+ on the runner.

name: Release
on:
  push:
    tags: ["v*"]
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          registry-url: https://registry.npmjs.org
      - name: Tag matches package.json
        run: |
          version=$(node -p "require('./package.json').version")
          if [ "v$version" != "$GITHUB_REF_NAME" ]; then
            echo "::error::tag $GITHUB_REF_NAME but package.json says $version"
            exit 1
          fi
      - run: npm ci
      - run: npm test
      - run: npm publish --access public
      - id: relnote
        uses: loki-inu/relnote@v0.3.7
      - env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          NOTES: ${{ steps.relnote.outputs.notes }}
        run: |
          printf '%s\n' "$NOTES" > notes.md
          gh release create "$GITHUB_REF_NAME" --verify-tag --notes-file notes.md

The first step stops the run if the tag and package.json disagree. npm ci installs from the lockfile committed with the tag. The GitHub Release is created only after the registry accepted the package. Its body is the action's notes output, passed through env: because commit subjects can contain backticks:

## What's changed

### Features

- **cli:** add --json output (`4e39256`) — Ada Lovelace

### Fixes

- handle empty input without panicking (`a8cc6d8`) — Ada Lovelace

### Other

- **docs:** document the --json flag (`83256ad`) — Ada Lovelace

---

Range: `v1.2.0` → `v1.3.0`.

**Full changelog:** https://github.com/you/demo/compare/v1.2.0...v1.3.0

The chore(release): v1.3.0 commit is left out of the notes by default.

FAQ

Why is package-lock.json in the release commit?

package-lock.json records your package's own version under packages[""].version. release.sh --bump-file package.json only rewrites package.json, so without a follow-up the lockfile still shows the old version and the next npm install dirties the tree. The --exec "npm install --package-lock-only --ignore-scripts --silent" step refreshes only the lockfile (no node_modules install, no lifecycle scripts), and release.sh commits every tracked file the command changed.

Does it work with npm workspaces?

If the workspace root is the published package, bump the root package.json and run the --exec from the root so the single package-lock.json updates. For packages versioned separately under packages/, use one tag prefix per package (release.sh --tag-prefix demo-core/v --bump-file packages/demo-core/package.json --exec "npm install --package-lock-only --ignore-scripts --silent" -- --path packages/demo-core) as on the monorepo page, and publish with npm publish -w.

Why trusted publishing instead of an npm token?

npm trusted publishing exchanges the job's OpenID Connect token for a short-lived publish credential, so there is no long-lived NPM_TOKEN in the repository. Provenance attestations are included automatically. Add a trusted publisher on npmjs.com for this repository with workflow release.yml and environment release before relying on it; publish the first version by hand with npm publish and a normal access token if the package does not exist yet.

What if the tag and package.json disagree?

The check step fails before npm ci runs, with an error naming both values. An npm version cannot be reused once published, so the check is worth keeping.

Can I dry-run the whole thing?

Locally, release.sh --dry-run prints the next version, the files it would bump, the --exec commands, and the notes, and changes nothing. npm publish --dry-run packs the tarball without uploading.

What about yarn or pnpm?

The bump still uses --bump-file package.json. For Yarn Berry, swap the --exec for yarn install --mode=update-lockfile; for pnpm, use pnpm install --lockfile-only. Keep the matching lockfile committed and use the package manager's ci-equivalent in the workflow (yarn install --immutable, pnpm install --frozen-lockfile).

Do I need semantic-release?

No. semantic-release is a good fit when every merge to main should publish from CI with plugins. This page is for people who want to cut the version, changelog, and tag locally from commit messages, then let a tag-push workflow publish and open the GitHub Release. Want that CI loop without Node in the release tooling? See the semantic-release alternative without npm/Node.