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>
228 lines
9.2 KiB
Markdown
228 lines
9.2 KiB
Markdown
# 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/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 <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_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=<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
|
|
|
|
```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.
|