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

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.