Files
small-scripts/specs/semver-ci.spec.md
Paul O'Reilly cde811fa0e Add semver-ci: commit-driven semantic versioning for Gitea Actions
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>
2026-04-17 19:30:44 +12:00

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, PATCH are derived from the last release tag plus commit-message-driven bumps since that tag.
  • BUILD is sourced fresh from CI and never reset. The BUILD value from a previous tag is ignored.
  • BRANCH is the current branch name with / replaced by - (so feature/foofeature-foo).
  • SHORTSHA is the first 7 characters of the current commit SHA.

Behaviour

  1. Verify the current directory is inside a git repository with at least one commit. Error and exit 1 otherwise.
  2. Determine the current branch:
    • If --branch NAME is passed, use that.
    • Else if $GITHUB_REF_NAME is set, use that.
    • Else use git rev-parse --abbrev-ref HEAD.
  3. Determine the main-branch name from --main-branch (default: main).
  4. Determine the current short SHA:
    • If $GITHUB_SHA is set, use its first 7 characters.
    • Else use git rev-parse --short=7 HEAD.
  5. Determine the "last release" tag:
    • If --base TAG is passed, use that tag (must exist).
    • Otherwise, list tags matching ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ and pick the highest by sort -V.
    • If none are found, treat base version as 0.0.0 and scan the full commit history.
  6. Parse MAJOR, MINOR, PATCH from the last tag (ignoring its BUILD).
  7. Collect commit messages since the last tag: git log <base>..HEAD --format=%B (or git log HEAD --format=%B when there is no base tag).
  8. 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.
  9. Apply bump rules (highest precedence wins for the whole range):
    • Any NEW_MAJORMAJOR += 1, MINOR = 0, PATCH = 0, released = true
    • Else any NEW_MINORMINOR += 1, PATCH = 0, released = true
    • Else any NEW_PATCHPATCH += 1, released = true
    • Else → MAJOR/MINOR/PATCH unchanged, released = false
  10. Determine BUILD:
    • --build N if given (must be a non-negative integer)
    • else $GITHUB_RUN_NUMBER if set
    • else $GITEA_RUN_NUMBER if set
    • else git rev-list --count HEAD
  11. 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.
  12. Print VERSION to stdout on a single line.
  13. If $GITHUB_OUTPUT is set, append:
    version=<VERSION>
    tag=<TAG or empty on non-main>
    released=<true|false>
    branch=<BRANCH>
    is_main=<true|false>
    
  14. 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>
      
  15. If branch != main-branch:
    • --tag, --push, --release each log "skipped on non-main branch" (to stderr) and are not performed.
    • Exit 0 after writing the version file and printing the version.
  16. 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: require GITEA_TOKEN (or GITHUB_TOKEN), GITHUB_SERVER_URL, GITHUB_REPOSITORY. POST to <server>/api/v1/repos/<repo>/releases with { "tag_name": "<TAG>", "name": "<TAG>", "body": "Automated release" }. Non-201 response is an error.

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.