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, 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:
- Checks out your target branch
- Bumps the version in
package.json - Runs
npm cito regeneratepackage-lock.json - 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.jsonversion to2.1.0- Regenerated
package-lock.json- Verified install with
npm ciTriggered via workflow dispatch by @hansel
No manual work. No forgotten lockfile updates. Just review and merge.
The Workflow
Create .github/workflows/bump-version.yml:
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:
- Go to your repo → Actions tab
- Select Bump Version from the left sidebar
- Click Run workflow
- Enter the new version number and target branch
- 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:
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.
- 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 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.
Top comments (1)
Worth noting: the regex semver validation can miss pre-release tags like 1.2.3-rc.1 if the pattern is too strict. We added a dry-run step that compares against npm's own semver parser before committing — caught a few edge cases the regex missed.