A reusable GitHub Action for automated semantic versioning driven by Conventional Commits. Designed for workflows with a staging branch (RC tags) and a production branch (final releases).
- Conventional commit analysis —
feat:,fix:,chore:,<type>!:,BREAKING CHANGE(spec) - Semver bump calculation from last production tag
- RC tag creation with sequential numbering (
v1.2.0-rc.1,v1.2.0-rc.2, ...) - Version escalation — higher-priority bumps reset RC numbering
- Categorized changelogs — Breaking, Features, Fixes, Maintenance, Other
- RC cleanup on production release (including orphaned RCs from escalation)
- Branch sync — production merged back to staging automatically
- GitHub Enterprise Server support via
github-api-urlinput
Prerequisites: Your repository must have a version file (
package.json,composer.json,pyproject.toml,VERSION, orChart.yaml) with a version field, and commits should follow Conventional Commits (feat:,fix:,chore:, etc.). The file format is auto-detected by filename.
name: Auto Version
on:
push:
branches: [master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false # queue runs so rapid pushes don't race on the same tag
jobs:
version:
runs-on: ubuntu-latest
if: >-
${{ !contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[ci skip]') }}
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Required: full history for commit analysis
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}Why
concurrency? Without it, several pushes landing close together (e.g. merging a batch of Dependabot PRs) start parallel runs that race to create the same tag and push the version bump; the losers fail withtag already exists/! [rejected] ... (non-fast-forward). The guard queues the runs so each bump completes in order. Keepcancel-in-progress: false— cancelling a run mid-push can leave a half-created tag. Apply the same guard to any downstream workflow that also commits or tags (e.g. a plugin-publish workflow).
name: Auto Version
on:
push:
branches: [master, staging]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false # queue runs so rapid pushes don't race on the same tag
jobs:
version:
runs-on: ubuntu-latest
if: >-
${{ !contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[ci skip]') }}
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Auto Version
id: version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}
# Use outputs in subsequent steps
- name: Print version info
run: |
echo "Version: ${{ steps.version.outputs.version }}"
echo "Bump type: ${{ steps.version.outputs.bump-type }}"
echo "RC version: ${{ steps.version.outputs.rc-version }}"
echo "Changed: ${{ steps.version.outputs.version-changed }}"For GHES instances, add github-api-url to ensure API calls reach the correct endpoint:
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}
github-api-url: ${{ github.api_url }}Note for GHES: Replace
runs-on: ubuntu-latestwith your self-hosted runner label (e.g.,runs-on: self-hosted). Use${{ github.api_url }}instead of hardcoding the API URL so it works automatically on any instance.
If the production or staging branch has branch protection that requires pull requests, the
default GITHUB_TOKEN (acting as github-actions[bot]) cannot push the bump commit and the
action fails with:
remote: error: GH006: Protected branch update failed for refs/heads/master.
remote: - Changes must be made through a pull request.
The action needs a token whose identity is on the branch protection
bypass_pull_request_allowances list. The default GITHUB_TOKEN is never on that list.
Pick one of the two options below depending on context.
| GitHub App token | Personal Access Token (PAT) | |
|---|---|---|
| Best for | Orgs, shared repos, long-lived automation | Personal / small repos |
| Setup complexity | Higher (register app, install, configure bypass) | Lower (generate PAT, add to bypass) |
| Identity | The GitHub App (e.g. my-org-bot) |
The user who generated the PAT |
| Lifetime | Installation token minted fresh per run (1 hour) | Manual rotation required before expiry |
| Survives user leaving | Yes | No (dies with the user account) |
| Works on GHES | Yes | Yes |
| Works on github.com | Yes | Yes |
Setup:
- Register a GitHub App (Settings → Developer settings → GitHub Apps → New App). Grant
repository
contents: writepermission. Note the App ID. - Generate and download the app's private key (PEM file).
- Install the app on the target repository.
- Add the app to the branch protection's Allow specified actors to bypass required pull requests list for the protected branch.
- In the repo (or org), store:
- The App ID as a variable (e.g.
AUTO_VERSION_APP_ID, not sensitive). - The private key as a secret (e.g.
AUTO_VERSION_APP_KEY).
- The App ID as a variable (e.g.
Workflow:
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.AUTO_VERSION_APP_ID }}
private-key: ${{ secrets.AUTO_VERSION_APP_KEY }}
github-api-url: ${{ github.api_url }}
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ steps.app-token.outputs.token }}
github-api-url: ${{ github.api_url }}Setup:
- Generate a classic or fine-grained PAT (Settings → Developer settings → Personal access tokens).
- Grant it
contents: writeon the repo. Addworkflows: writeonly if the action ever edits workflow files. - Add the PAT's user to the branch protection bypass list (or the user already has admin privileges that bypass protection).
- Store the PAT as a repo/org secret (e.g.
RELEASE_PAT).
Workflow:
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
persist-credentials: true
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.RELEASE_PAT }}The same token pattern unblocks any automated commit from a workflow (data refresh bots, Renovate, etc.) against a protected branch. Humans still go through pull requests; only the app or PAT identity is allowed to push directly.
| Input | Required | Default | Description |
|---|---|---|---|
version-file |
yes | — | Path to version file (package.json, composer.json, pyproject.toml, VERSION, Chart.yaml). Auto-detected by filename. |
helm-chart |
no | "" |
Path to Chart.yaml to update appVersion |
staging-branch |
no | staging |
Name of the staging/RC branch |
production-branch |
no | master |
Name of the production branch |
github-token |
yes | — | Token for creating releases and tags |
github-api-url |
no | https://api.github.com |
API URL for GitHub Enterprise Server |
update-floating-tags |
no | "false" |
Update vMAJOR and vMAJOR.MINOR floating tags on production release |
deployment-info |
no | "" |
Markdown block for deployment section in release notes |
| Output | Description |
|---|---|
version |
Base version (e.g., 2.1.1) |
rc-version |
Full RC version if staging (e.g., 2.1.1-rc.3) |
rc-number |
RC number (e.g., 3) |
bump-type |
major, minor, patch, or none |
version-changed |
true if version was bumped |
Commit to staging -> Analyze commits -> Bump version -> Create RC tag -> Pre-release
Merge to master -> Read version -> Create tag -> Release -> Cleanup RCs
Follows the Conventional Commits specification. Automated commits containing [skip ci] are filtered out before analysis to prevent phantom version bumps.
| Commit prefix | Bump type | Example |
|---|---|---|
<type>!: or BREAKING CHANGE |
major | feat!: redesign API, fix!: change auth flow |
feat: |
minor | feat: add search filter |
fix:, chore:, docs:, etc. |
patch | fix: resolve null pointer |
The ! modifier works on any commit type (feat!:, fix!:, chore!:, refactor!:, etc.) to signal a breaking change, per the spec.
Commit types are also detected when preceded by an issue reference (e.g., #123 feat: add feature or web/repo#42 fix: resolve bug).
The action analyzes all commits since the last production release to determine the highest-priority bump:
Production: v1.0.0
fix: bug A -> v1.0.1-rc.1 (patch)
fix: bug B -> v1.0.1-rc.2 (same priority, increment RC)
feat: new feature -> v1.1.0-rc.1 (higher priority, re-bump + reset RC)
fix: bug C -> v1.1.0-rc.2 (lower priority, increment RC)
feat!: breaking -> v2.0.0-rc.1 (highest priority, re-bump + reset RC)
- Analyze all commits since last production tag
- Determine bump type (major > minor > patch)
- If RC tags exist: compare priority, re-bump if higher
- Update version file (and optional
Chart.yaml) - Commit with
[skip ci], create RC tag, create GitHub Pre-release
The production branch never bumps the version or pushes commits in two-branch mode. The correct version arrives via the staging merge. This avoids issues with protected branches and race conditions when staging auto-version has not yet completed.
Two-branch mode (staging branch exists on remote):
- Read the current version from the version file (set by the staging merge)
- If a release tag (
v{version}) already exists, skip (already released) - If RC tags exist for this version, proceed: output
version_changed=false, letcreate-release.shcreate the production tag and release - If no RC tags and the merge came from staging, skip (staging cycle incomplete, auto-version has not run yet)
- If no RC tags and the merge did NOT come from staging (hotfix), use the version from the version file as-is (no bump, no push)
Single-branch mode (no staging branch on remote):
- Analyze commits and calculate the expected version (same as old behavior)
- If the current version is already correct, use it
- If outdated, bump the version file, commit, and push
After bump-version.sh, subsequent steps handle tag creation, release notes, RC cleanup,
and branch sync.
Production behavior by scenario:
| Scenario | Staging exists? | RC tags? | Merge from staging? | Result |
|---|---|---|---|---|
| Normal release | yes | yes | yes | Read version, create release (no bump) |
| Normal release (hotfix) | yes | no | no | Read version, create release (no bump) |
| Staging merge before RC cycle | yes | no | yes | Skip (staging cycle incomplete) |
| Already released | yes | n/a | n/a | Skip (tag exists) |
| Single-branch mode | no | n/a | n/a | Bump + release (full flow) |
The recommended flow for applications with pre-production environments. Commits go to staging first, creating RC pre-releases, then merge to production for the final release.
on:
push:
branches: [master, staging]For simpler projects that don't need RC releases. Push directly to the production branch — the action analyzes commits, bumps the version, and creates the release in one step.
on:
push:
branches: [master]Both modes use the same action configuration. The difference is only which branches trigger the workflow.
The action auto-detects the version file format by filename and uses jq/sed for reading and writing. No Node.js required.
| Ecosystem | Version file | Read | Write |
|---|---|---|---|
| Node.js / Next.js / React | package.json |
jq -r '.version' |
jq '.version = "X"' |
| PHP / Drupal | composer.json |
jq -r '.version' |
jq '.version = "X"' |
| Python | pyproject.toml |
grep + sed |
sed -i |
| Plain / Shell | VERSION or VERSION.txt |
cat |
echo > file |
| YAML / Helm | Chart.yaml / *.yaml |
grep + sed |
sed -i |
| Helm Charts (appVersion) | Chart.yaml via helm-chart input |
— | sed -i |
Updates appVersion in your Helm Chart.yaml alongside the version file:
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: js-app/package.json
helm-chart: helm/my-app/Chart.yaml
github-token: ${{ secrets.GITHUB_TOKEN }}Use develop/main instead of the default staging/master:
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}
staging-branch: develop
production-branch: main - name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: composer.json
github-token: ${{ secrets.GITHUB_TOKEN }} - name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: pyproject.toml
github-token: ${{ secrets.GITHUB_TOKEN }}For repos without a package manager (shell scripts, documentation, etc.):
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: VERSION
github-token: ${{ secrets.GITHUB_TOKEN }}Add environment details to the release notes:
- name: Auto Version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}
deployment-info: |
- **Environment**: ${{ github.ref_name == 'master' && 'Production' || 'Staging' }}
- **Cluster**: `my-k8s-cluster`Use the version output to tag Docker images:
- name: Auto Version
id: version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}
github-api-url: ${{ github.api_url }}
- name: Build and push Docker image
if: steps.version.outputs.version-changed == 'true'
run: |
docker build -t my-registry/my-app:${{ steps.version.outputs.version }} .
docker push my-registry/my-app:${{ steps.version.outputs.version }}Only deploy when the version actually changed:
- name: Auto Version
id: version
uses: lucaspretti/auto-version-action@v1
with:
version-file: package.json
github-token: ${{ secrets.GITHUB_TOKEN }}
github-api-url: ${{ github.api_url }}
- name: Deploy to staging
if: >-
steps.version.outputs.version-changed == 'true' &&
github.ref_name == 'staging'
run: |
echo "Deploying RC ${{ steps.version.outputs.rc-version }}..."
# your deploy script here
- name: Deploy to production
if: github.ref_name == 'master'
run: |
echo "Deploying v${{ steps.version.outputs.version }}..."
# your deploy script hereThis action uses floating tags so consumers always get the latest fixes:
| Reference | Resolves to | Use case |
|---|---|---|
@v1 |
Latest v1.x.x release |
Recommended — always up to date |
@v1.2 |
Latest v1.2.x patch |
Pin to a minor version |
@v1.2.3 |
Exact version | Full reproducibility |
Floating tags (v1, v1.2) are updated automatically when update-floating-tags: "true" is set. This is useful for GitHub Actions consumed by other repos. Off by default.
auto-version-action/
├── action.yml # Composite action definition
├── scripts/
│ ├── version-utils.sh # Shared read/write version functions (auto-detects file format)
│ ├── analyze-commits.sh # Commit analysis + bump type detection
│ ├── bump-version.sh # Version bump + RC numbering logic
│ ├── create-release.sh # Release/pre-release creation with changelog
│ ├── cleanup-rc.sh # RC pre-release cleanup on production
│ ├── update-floating-tags.sh # Move vMAJOR/vMAJOR.MINOR tags (opt-in)
│ └── summary.sh # GitHub Actions step summary
├── tests/
│ ├── run-all.sh # Test runner
│ ├── test-helper.sh # Minimal bash assertion framework
│ ├── test-analyze-commits.sh
│ ├── test-bump-version.sh
│ └── test-version-utils.sh
└── README.md
When using branch protection on the production branch (e.g., requiring pull requests), the action needs permission to push the version bump commit directly in single-branch mode. Add the GitHub Actions app to the "Allow specified actors to bypass required pull requests" list in branch protection settings.
In two-branch mode, the production flow never pushes commits (the version comes from the staging merge), so branch protection is not an issue.
See docs/edge-cases-and-findings.md for:
- Bugs found and fixed during real-world usage
- Known behaviors (reverted commits in changelogs, orphaned tags, etc.)
- Open items and future work
- Reference implementation details from real-world integrations
- jq — used for JSON version files and release API calls
- sed — used for TOML/YAML version files
- Git — with full history (
fetch-depth: 0on checkout)
MIT