Manually bumping a package version is one of those low-value chores that's easy to forget, easy to mess up, and just annoying enough to interrupt your flow. You change `package.json`, regenerate the lockfile, commit it, open a PR — every time, for every release.
There's a better way: let a GitHub Actions workflow handle the entire thing on demand.
Here's an example project with the workflow pattern I set up for one of my open source dev tool [line-commenter-tool](https://github.com/darkmastermindz/line-commenter-tool), which you can adapt for any Node.js project.
---
## What It Does
When you're ready to cut a new version, you trigger a workflow dispatch from the GitHub Actions UI (or via the API/CLI). The workflow:
1. Checks out your target branch
2. Bumps the version in `package.json`
3. Runs `npm ci` to regenerate `package-lock.json`
4. Opens a pull request with a clean commit and auto-generated description
The resulting PR looks similar to this:
> **chore: bump version to 2.1.0**
>
> - Updated `package.json` version to `2.1.0`
> - Regenerated `package-lock.json`
> - Verified install with `npm ci`
>
> *Triggered via workflow dispatch by @hansel*
No manual work. No forgotten lockfile updates. Just review and merge.
---
## The Workflow
Create `.github/workflows/bump-version.yml`:
```yaml
on:
workflow_dispatch:
inputs:
version:
description: 'New semver version (e.g. 2.2.0)'
required: true
type: string
target_branch:
description: "Branch to bump version on"
required: true
default: "main"
jobs:
bump-version:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 20
- name: Validate version input
id: validate
run: |
VERSION="${{ github.event.inputs.version }}"
# Validate semver format (major.minor.patch with optional pre-release/build metadata)
if ! echo "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$'; then
echo "ERROR: '$VERSION' is not a valid semver version."
exit 1
fi
CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current version: $CURRENT_VERSION"
echo "Requested version: $VERSION"
# Validate new version is strictly greater than current using Node
node -e "
const parseBase = (v) => v.replace(/-.*/, '').split('.').map(Number);
const curr = parseBase('$CURRENT_VERSION');
const next = parseBase('$VERSION');
for (let i = 0; i < 3; i++) {
if (next[i] > curr[i]) { console.log('Validation passed: $VERSION > $CURRENT_VERSION'); process.exit(0); }
if (next[i] < curr[i]) {
console.error('ERROR: $VERSION is not greater than current version $CURRENT_VERSION');
process.exit(1);
}
}
console.error('ERROR: $VERSION must be strictly greater than current version $CURRENT_VERSION (equal versions are not allowed)');
process.exit(1);
"
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create release branch
run: git checkout -b "chore/bump-version-${{ github.event.inputs.version }}"
- name: Bump version in package.json
run: npm version ${{ github.event.inputs.version }} --no-git-tag-version
- name: Regenerate package-lock.json
run: npm install --package-lock-only
- name: Verify with npm ci
run: npm ci
- name: Commit changes
run: |
git add package.json package-lock.json
git commit -m "chore: bump version to ${{ github.event.inputs.version }}"
- name: Push branch
run: git push origin "chore/bump-version-${{ github.event.inputs.version }}"
- name: Create Pull Request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BODY=$(cat <<'PREOF'
## Version Bump
Bumps the package version from `CURRENT_PLACEHOLDER` to `VERSION_PLACEHOLDER`.
### Changes
- Updated `package.json` version to `VERSION_PLACEHOLDER`
- Regenerated `package-lock.json`
- Verified install with `npm ci`
---
_Triggered via workflow dispatch by @ACTOR_PLACEHOLDER_
PREOF
)
BODY="${BODY//CURRENT_PLACEHOLDER/${{ steps.validate.outputs.current }}}"
BODY="${BODY//VERSION_PLACEHOLDER/${{ github.event.inputs.version }}}"
BODY="${BODY//ACTOR_PLACEHOLDER/${{ github.actor }}}"
gh pr create \
--title "chore: bump version to ${{ github.event.inputs.version }}" \
--body "$BODY" \
--base ${{ github.event.inputs.target_branch }} \
--head "chore/bump-version-${{ github.event.inputs.version }}"
```
Aside: Alternatively, you can try setting default input `target_branch` to `${{ github.ref_name }}` if you prefer to bump versions into feature branches rather than main and dynamically reference the current branch you are targeting.
---
## How to Use It
**From the GitHub UI:**
1. Go to your repo → **Actions** tab
2. Select **Bump Version** from the left sidebar
3. Click **Run workflow**
4. Enter the new version number and target branch
5. Hit **Run workflow**
Within seconds, a PR appears targeting your branch. Review the diff (should just be `package.json` and `package-lock.json`), then merge.
**From the CLI with `gh`:**
```bash
gh workflow run bump-version.yml \
-f version=2.1.0 \
-f target_branch=my-feature-branch
```
---
## Why This Pattern Works Well
**It fits into your existing review process.** The version bump goes through a PR like any other change. You get to review it, run your CI checks, and merge intentionally.
**The bot commit is honest.** The commit is attributed to `github-actions[bot]`, so your git history clearly shows which bumps were automated versus manual. No noise in your contributor graph.
**It handles the lockfile correctly.** Running `npm ci` after the version bump ensures the lockfile is consistent. This catches any edge cases where a stale lockfile might cause issues downstream.
**It targets feature branches, not just main.** Because `target_branch` is an input, you can bump the version on a release branch or a feature branch mid-development — useful when you're preparing multiple releases in parallel.
---
## Variations Worth Considering
**Auto-detect the current version and increment it:** Instead of specifying the full version, accept a bump type (`patch`, `minor`, `major`) and use `npm version patch --no-git-tag-version` to let npm calculate it.
```yaml
- name: Bump version
run: npm version ${{ github.event.inputs.bump_type }} --no-git-tag-version
```
**Auto-merge on approval:** Add a `pull_request_review` trigger or use a merge queue to automatically merge version bump PRs after CI passes, removing the need to manually merge them.
**Tag the release after merge:** Chain a second workflow that listens for merged PRs with the `chore/bump-version-*` naming pattern and creates a git tag automatically.
---
## Wrapping Up
This is a small workflow but it removes a surprisingly persistent source of friction. Once it's in place, you stop thinking about version bumps entirely — you just trigger the workflow, review the PR, and merge.
The full example is live in the [line-commenter-tool repo](https://github.com/darkmastermindz/line-commenter-tool/pull/93) if you want to see what the generated PR looks like in practice.
---
*Have a variation of this pattern that works well for your project? Drop it in the comments.*