relnote docs · GitHub Action · CHANGELOG.md · Python packages · Rust crates · standard-version 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.
+ + +