Computes MAJOR.MINOR.PATCH.BUILD from NEW_MAJOR/NEW_MINOR/NEW_PATCH whole-line tokens in commit messages since the last release tag. Non-main branches get a BRANCH-SHORTSHA suffix and never release. Maintains VERSION.md, writes $GITHUB_OUTPUT, and optionally creates a Gitea release via API. Includes spec, 67-assertion test, usage guide (docs/semver-ci.md), and a drop-in Gitea Actions workflow template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.2 KiB
semver-ci — usage guide
semver-ci computes the next semantic version for a repository from its commit history, maintains a VERSION.md file, and — on the main branch — creates a git tag and a Gitea release when MAJOR, MINOR, or PATCH actually changes.
Version format:
- Main branch:
MAJOR.MINOR.PATCH.BUILD(e.g.1.4.2.317) - Other branches:
MAJOR.MINOR.PATCH.BUILD-BRANCH-SHORTSHA(e.g.1.4.2.317-feature-auth-a1b2c3d)
See also: the full spec at ../specs/semver-ci.spec.md and the reusable Gitea Actions workflow at ../templates/gitea-workflow-version.yml.
Quick start
- Make the script available on your PATH.
ln -sf ~/dev/claude/small-scripts/scripts/semver-ci ~/sbin/semver-ci - Copy the workflow template into your project.
mkdir -p .gitea/workflows cp ~/dev/claude/small-scripts/templates/gitea-workflow-version.yml \ .gitea/workflows/version.yml - Configure a
GITEA_TOKENsecret on the repo (Settings → Actions → Secrets) with permission to create releases. - Push a commit whose message contains
NEW_PATCH,NEW_MINOR, orNEW_MAJORon a line of its own.
On the first run you will get 0.0.1.<build> (or higher, depending on the trigger you used) and a release tagged v0.0.1.<build>.
Commit-message conventions
Put exactly one of the trigger tokens on a line of its own — leading and trailing whitespace are allowed, but anything else on that line disqualifies it.
| Token | Effect | Precedence |
|---|---|---|
NEW_MAJOR |
MAJOR += 1, MINOR = 0, PATCH = 0, new release |
highest |
NEW_MINOR |
MINOR += 1, PATCH = 0, new release |
middle |
NEW_PATCH |
PATCH += 1, new release |
lowest |
Examples — these do trigger:
feat: search endpoint
NEW_MINOR
Adds a faceted search API with …
fix: transient DB connection error
NEW_PATCH
NEW_PATCH
These do not trigger (token is not alone on its line):
refactor: see NEW_PATCH notes for details
NEW_PATCH fixes a CVE
Multiple triggers across a range of commits collapse into a single bump: the highest-precedence token wins. You never get two bumps from a single build.
BUILD number
BUILD always increases and is never reset between releases. The source is chosen in this order (first hit wins):
--build Ncommand-line flag$GITHUB_RUN_NUMBER(set by Gitea Actions)$GITEA_RUN_NUMBERgit rev-list --count HEAD(monotonic fallback for local runs)
Branch handling
- On the main branch (
mainby default; override with--main-branch NAME), versions areM.m.p.Band--tag/--push/--releaseoperate as documented. - On any other branch the version becomes
M.m.p.B-BRANCH-SHORTSHA.--tag,--push, and--releasebecome no-ops and log a stderr notice.VERSION.mdis still refreshed. /in a branch name is replaced by-in the suffix (sofeature/login→feature-login).- Override auto-detection with
--branch NAMEfor local previews or custom CI setups.
VERSION.md
By default the script writes VERSION.md in the git repo root:
# Version
**1.4.2.317**
- Major: 1
- Minor: 4
- Patch: 2
- Build: 317
- Branch: main
- Commit: a1b2c3d
- Tag: v1.4.2.317
- Generated: 2026-04-17T09:12:33Z
Two common patterns for handling this file:
- Treat as a build artifact (recommended). Add
VERSION.mdto.gitignore; let CI regenerate it each build and publish it as a release asset or workflow artifact. The template usesactions/upload-artifact. - Commit it manually. Run
semver-ci --dryrunlocally before pushing a release commit, then runsemver-ci(without--dryrun) and commitVERSION.mdwith the same PR. Do not have CI auto-commit it — that creates a push loop.
Customise the path with --version-file PATH, or skip writing with --no-version-file.
Using locally
# Preview what the next build would be, without side effects
semver-ci --dryrun
# Emit just the version string (useful for scripting)
semver-ci --build 42 --no-version-file
# Produce a dev build with an explicit branch name
semver-ci --dryrun --branch feature/auth --build 77
Using in Gitea Actions
The supplied workflow template installs semver-ci from the small-scripts repo, runs it with --release, and uploads VERSION.md as an artifact. The relevant step:
- name: Compute version & release
id: ver
run: semver-ci --release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
Downstream steps can read the outputs:
- name: Build container
run: |
docker build -t myapp:${{ steps.ver.outputs.version }} .
docker push myapp:${{ steps.ver.outputs.version }}
Outputs emitted to $GITHUB_OUTPUT:
| Name | Example | Notes |
|---|---|---|
version |
1.4.2.317 or 1.4.2.317-feature-x-abc1234 |
always set |
tag |
v1.4.2.317 |
empty on non-main branches |
released |
true / false |
true when M/m/p actually changed |
branch |
main / feature/x |
raw branch name (no / → - substitution) |
is_main |
true / false |
convenience flag for if: conditions |
Flag reference
| Flag | Default | Purpose |
|---|---|---|
-n, --dryrun |
off | Preview; no side effects |
--tag |
off | Create the tag locally (main-only) |
--push |
off | Also push tag to origin (implies --tag, main-only) |
--release |
off | Also POST a Gitea release (implies --push, main-only, requires M/m/p change) |
--build N |
env → git count | Override BUILD |
--base TAG |
auto | Override last-release tag detection |
--branch NAME |
env → git | Override current-branch detection |
--main-branch NAME |
main |
Which branch is considered "main" |
--version-file PATH |
<repo>/VERSION.md |
Where to write the version file |
--no-version-file |
off | Skip writing the version file |
Troubleshooting
"Cannot determine BUILD number" — you're not in CI and the repo has no commits yet, or git rev-list --count HEAD failed. Pass --build N explicitly.
Release not created even though I pushed NEW_PATCH — releases happen only on the main branch. Verify steps.ver.outputs.is_main == 'true' and released == 'true' in the workflow log, or re-run locally with --dryrun to see what semver-ci detected.
Tag already exists — someone else (or a previous run) already created the tag. If you rebuilt the same commit, the resulting BUILD will be the same and the tag will collide. Either bump BUILD (re-run the workflow — GITHUB_RUN_NUMBER will be higher) or delete the conflicting tag if it was mistaken.
Branch push created a release anyway — double-check --main-branch. If your main branch isn't named main, pass --main-branch master (or whatever) in the workflow step.
Gitea release API 401/403 — the GITEA_TOKEN secret is missing or lacks repo-write scope. Mint a new token in User Settings → Applications and grant write:repository.
sort -V picked the wrong tag — only tags matching ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ are considered. Older tags in other formats are ignored, which is usually what you want. If you need to pin a specific base, use --base vX.Y.Z.B.