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>
9.2 KiB
semver-ci
Purpose
Compute the next semantic version from commit-message tokens since the last release, maintain a VERSION.md file in the repo root, and (on the main branch only) optionally create the git tag and Gitea release. Designed to run as a step in a Gitea Actions workflow, but usable standalone.
Usage
semver-ci [OPTIONS]
Flags
| Flag | Description |
|---|---|
-h, --help |
Show usage |
-n, --dryrun |
Preview what would happen without side effects |
--tag |
Create the new tag locally (main branch only; ignored on branches with a log message) |
--push |
Push the new tag to origin (implies --tag; main-only) |
--release |
Create a Gitea release via API (implies --push; main-only; additionally no-op when MAJOR/MINOR/PATCH are unchanged) |
--build N |
Override BUILD number (otherwise sourced from env / git) |
--base TAG |
Override the "last release" tag auto-detection |
--branch NAME |
Override the current branch detection |
--main-branch NAME |
Name of the main branch (default: main) |
--version-file PATH |
Path to the version file to maintain (default: <repo-root>/VERSION.md) |
--no-version-file |
Skip writing the version file |
Version Format
There are two formats, depending on which branch is being built:
- Main branch:
MAJOR.MINOR.PATCH.BUILD(tag:vMAJOR.MINOR.PATCH.BUILD) - Other branches:
MAJOR.MINOR.PATCH.BUILD-BRANCH-SHORTSHA(no tag created)
Where:
MAJOR,MINOR,PATCHare derived from the last release tag plus commit-message-driven bumps since that tag.BUILDis sourced fresh from CI and never reset. The BUILD value from a previous tag is ignored.BRANCHis the current branch name with/replaced by-(sofeature/foo→feature-foo).SHORTSHAis the first 7 characters of the current commit SHA.
Behaviour
- Verify the current directory is inside a git repository with at least one commit. Error and exit 1 otherwise.
- Determine the current branch:
- If
--branch NAMEis passed, use that. - Else if
$GITHUB_REF_NAMEis set, use that. - Else use
git rev-parse --abbrev-ref HEAD.
- If
- Determine the main-branch name from
--main-branch(default:main). - Determine the current short SHA:
- If
$GITHUB_SHAis set, use its first 7 characters. - Else use
git rev-parse --short=7 HEAD.
- If
- Determine the "last release" tag:
- If
--base TAGis passed, use that tag (must exist). - Otherwise, list tags matching
^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$and pick the highest bysort -V. - If none are found, treat base version as
0.0.0and scan the full commit history.
- If
- Parse
MAJOR,MINOR,PATCHfrom the last tag (ignoring itsBUILD). - Collect commit messages since the last tag:
git log <base>..HEAD --format=%B(orgit log HEAD --format=%Bwhen there is no base tag). - For each line in those messages, check whether the entire line matches one of the trigger tokens:
- Regex:
^[[:space:]]*NEW_(MAJOR|MINOR|PATCH)[[:space:]]*$ - Case-sensitive. Leading/trailing whitespace tolerated; any other non-whitespace disqualifies the line.
- Regex:
- Apply bump rules (highest precedence wins for the whole range):
- Any
NEW_MAJOR→MAJOR += 1,MINOR = 0,PATCH = 0, released = true - Else any
NEW_MINOR→MINOR += 1,PATCH = 0, released = true - Else any
NEW_PATCH→PATCH += 1, released = true - Else → MAJOR/MINOR/PATCH unchanged, released = false
- Any
- Determine
BUILD:--build Nif given (must be a non-negative integer)- else
$GITHUB_RUN_NUMBERif set - else
$GITEA_RUN_NUMBERif set - else
git rev-list --count HEAD
- Compose the version string:
- If branch == main-branch:
VERSION = MAJOR.MINOR.PATCH.BUILD,TAG = vVERSION. - Else:
SAFE_BRANCH = BRANCH with / replaced by -;VERSION = MAJOR.MINOR.PATCH.BUILD-SAFE_BRANCH-SHORTSHA; no tag.
- If branch == main-branch:
- Print
VERSIONto stdout on a single line. - If
$GITHUB_OUTPUTis set, append:version=<VERSION> tag=<TAG or empty on non-main> released=<true|false> branch=<BRANCH> is_main=<true|false> - Write the version file (unless
--no-version-file):- Default path:
<git-repo-root>/VERSION.md. Path may be overridden with--version-file. - Content (markdown):
# Version **<VERSION>** - Major: <MAJOR> - Minor: <MINOR> - Patch: <PATCH> - Build: <BUILD> - Branch: <BRANCH> - Commit: <SHORTSHA> - Tag: <TAG or "-" on non-main> - Generated: <ISO-8601 UTC timestamp>
- Default path:
- If branch != main-branch:
--tag,--push,--releaseeach log "skipped on non-main branch" (to stderr) and are not performed.- Exit 0 after writing the version file and printing the version.
- If branch == main-branch:
- If
--tag:git tag <TAG>(error if tag already exists). - If
--push:git push origin <TAG>. - If
--release:- When
released = false: log "release skipped (no MAJOR/MINOR/PATCH change)" to stderr; exit 0. - When
released = true: requireGITEA_TOKEN(orGITHUB_TOKEN),GITHUB_SERVER_URL,GITHUB_REPOSITORY. POST to<server>/api/v1/repos/<repo>/releaseswith{ "tag_name": "<TAG>", "name": "<TAG>", "body": "Automated release" }. Non-201response is an error.
- When
- If
Dryrun Behaviour
When --dryrun / -n is passed, no git tag, push, API call, or file write is performed. The output is:
[dryrun] Branch: main (main-branch detected)
[dryrun] Commit: abc1234
[dryrun] Last release: v1.2.3.104
[dryrun] Commits scanned: 7
[dryrun] Triggers found: NEW_MINOR (1), NEW_PATCH (2)
[dryrun] Version bump: MINOR
[dryrun] New version: 1.3.0.456
[dryrun] Would write version file: /repo/VERSION.md
[dryrun] Would create tag: v1.3.0.456
[dryrun] Would push tag to origin
[dryrun] Would create Gitea release for v1.3.0.456
On a non-main branch:
[dryrun] Branch: feature/foo (non-main)
[dryrun] Commit: abc1234
[dryrun] Last release: v1.2.3.104
[dryrun] Commits scanned: 2
[dryrun] Triggers found: none
[dryrun] Version bump: none
[dryrun] New version: 1.2.3.456-feature-foo-abc1234
[dryrun] Would write version file: /repo/VERSION.md
[dryrun] Would skip tag/push/release on non-main branch
[dryrun] Last release prints <none> if no matching tags exist. Triggers found prints none and Version bump prints none when applicable. Lines for tag/push/release only appear if the corresponding flag is set.
Edge Cases
| Case | Handling |
|---|---|
| No matching tags in repo | Base = 0.0.0, scan all commits. |
| HEAD already at last release tag | 0 commits scanned, no bump, released = false. |
| Same trigger appears multiple times | Still treated as a single bump. |
| Trigger mentioned inline in prose | Ignored — only whole-line matches count. |
Branch contains / |
Replaced by - in version suffix. Raw branch name preserved in GITHUB_OUTPUT and VERSION.md. |
| No BUILD source available | Error: "Cannot determine BUILD number", exit 1. |
--build N is not a non-negative integer |
Error, exit 1. |
--base TAG does not exist |
Error, exit 1. |
| Repo has no commits | Error, exit 1. |
| Not inside a git repository | Error, exit 1. |
--tag where tag already exists |
Error, exit 1. |
--release on main with released = false |
Log skip message, exit 0. |
--tag / --push / --release on non-main |
Logged as skipped (stderr), not performed; exit 0. |
--release on main with released = true but missing env (token/server/repo) |
Error, exit 1. |
--version-file PATH points into a non-existent directory |
Error, exit 1. |
Examples
Gitea Actions workflow snippet
jobs:
version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + tags
- name: Compute version & release
id: ver
run: semver-ci --release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Use it
run: echo "Built ${{ steps.ver.outputs.version }} (released=${{ steps.ver.outputs.released }})"
The same step works on feature branches: --release is a no-op there, but VERSION.md is still refreshed and version is still set in step outputs.
Local preview (main branch)
$ semver-ci --dryrun
[dryrun] Branch: main (main-branch detected)
[dryrun] Commit: abc1234
[dryrun] Last release: v1.2.3.104
[dryrun] Commits scanned: 5
[dryrun] Triggers found: NEW_PATCH (1)
[dryrun] Version bump: PATCH
[dryrun] New version: 1.2.4.123
[dryrun] Would write version file: /home/me/proj/VERSION.md
Local preview (feature branch)
$ semver-ci --dryrun --branch feature/login --build 77
[dryrun] Branch: feature/login (non-main)
[dryrun] Commit: abc1234
[dryrun] Last release: v1.2.3.104
[dryrun] Commits scanned: 3
[dryrun] Triggers found: NEW_PATCH (1)
[dryrun] Version bump: PATCH
[dryrun] New version: 1.2.4.77-feature-login-abc1234
[dryrun] Would write version file: /home/me/proj/VERSION.md
Commit message trigger format
feat: add search endpoint
NEW_MINOR
Detailed rationale here...
The trigger line must be entirely NEW_MINOR (whitespace around it tolerated). Mentioning NEW_MINOR inline in prose does not trigger.