# 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: `/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/foo` → `feature-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 ..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_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** 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= tag= released= branch= is_main= ``` 14. Write the version file (unless `--no-version-file`): - Default path: `/VERSION.md`. Path may be overridden with `--version-file`. - Content (markdown): ``` # Version **** - Major: - Minor: - Patch: - Build: - Branch: - Commit: - Tag: - Generated: ``` 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 ` (error if tag already exists). - If `--push`: `git push origin `. - 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 `/api/v1/repos//releases` with `{ "tag_name": "", "name": "", "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 `` 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 ```yaml 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) ```bash $ 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) ```bash $ 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.