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>
This commit is contained in:
227
specs/semver-ci.spec.md
Normal file
227
specs/semver-ci.spec.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user