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:
Paul O'Reilly
2026-04-17 19:29:40 +12:00
parent 340c40392a
commit cde811fa0e
6 changed files with 1264 additions and 0 deletions

184
docs/semver-ci.md Normal file
View File

@@ -0,0 +1,184 @@
# semver-ci — usage guide
`semver-ci` computes the next semantic version for a repository from its commit history, maintains a `VERSION.md` file, and — on the main branch — creates a git tag and a Gitea release when MAJOR, MINOR, or PATCH actually changes.
Version format:
- **Main branch:** `MAJOR.MINOR.PATCH.BUILD` (e.g. `1.4.2.317`)
- **Other branches:** `MAJOR.MINOR.PATCH.BUILD-BRANCH-SHORTSHA` (e.g. `1.4.2.317-feature-auth-a1b2c3d`)
See also: the full spec at [`../specs/semver-ci.spec.md`](../specs/semver-ci.spec.md) and the reusable Gitea Actions workflow at [`../templates/gitea-workflow-version.yml`](../templates/gitea-workflow-version.yml).
## Quick start
1. Make the script available on your PATH.
```bash
ln -sf ~/dev/claude/small-scripts/scripts/semver-ci ~/sbin/semver-ci
```
2. Copy the workflow template into your project.
```bash
mkdir -p .gitea/workflows
cp ~/dev/claude/small-scripts/templates/gitea-workflow-version.yml \
.gitea/workflows/version.yml
```
3. Configure a `GITEA_TOKEN` secret on the repo (Settings → Actions → Secrets) with permission to create releases.
4. Push a commit whose message contains `NEW_PATCH`, `NEW_MINOR`, or `NEW_MAJOR` on a line of its own.
On the first run you will get `0.0.1.<build>` (or higher, depending on the trigger you used) and a release tagged `v0.0.1.<build>`.
## Commit-message conventions
Put exactly one of the trigger tokens on a **line of its own** — leading and trailing whitespace are allowed, but anything else on that line disqualifies it.
| Token | Effect | Precedence |
|-------|--------|------------|
| `NEW_MAJOR` | `MAJOR += 1`, `MINOR = 0`, `PATCH = 0`, new release | highest |
| `NEW_MINOR` | `MINOR += 1`, `PATCH = 0`, new release | middle |
| `NEW_PATCH` | `PATCH += 1`, new release | lowest |
Examples — these **do** trigger:
```
feat: search endpoint
NEW_MINOR
Adds a faceted search API with …
```
```
fix: transient DB connection error
NEW_PATCH
```
```
NEW_PATCH
```
These do **not** trigger (token is not alone on its line):
```
refactor: see NEW_PATCH notes for details
```
```
NEW_PATCH fixes a CVE
```
Multiple triggers across a range of commits collapse into a single bump: the highest-precedence token wins. You never get two bumps from a single build.
## BUILD number
`BUILD` always increases and is never reset between releases. The source is chosen in this order (first hit wins):
1. `--build N` command-line flag
2. `$GITHUB_RUN_NUMBER` (set by Gitea Actions)
3. `$GITEA_RUN_NUMBER`
4. `git rev-list --count HEAD` (monotonic fallback for local runs)
## Branch handling
- On the main branch (`main` by default; override with `--main-branch NAME`), versions are `M.m.p.B` and `--tag`/`--push`/`--release` operate as documented.
- On any other branch the version becomes `M.m.p.B-BRANCH-SHORTSHA`. `--tag`, `--push`, and `--release` become no-ops and log a stderr notice. `VERSION.md` is still refreshed.
- `/` in a branch name is replaced by `-` in the suffix (so `feature/login` → `feature-login`).
- Override auto-detection with `--branch NAME` for local previews or custom CI setups.
## VERSION.md
By default the script writes `VERSION.md` in the git repo root:
```
# Version
**1.4.2.317**
- Major: 1
- Minor: 4
- Patch: 2
- Build: 317
- Branch: main
- Commit: a1b2c3d
- Tag: v1.4.2.317
- Generated: 2026-04-17T09:12:33Z
```
Two common patterns for handling this file:
- **Treat as a build artifact (recommended).** Add `VERSION.md` to `.gitignore`; let CI regenerate it each build and publish it as a release asset or workflow artifact. The template uses `actions/upload-artifact`.
- **Commit it manually.** Run `semver-ci --dryrun` locally before pushing a release commit, then run `semver-ci` (without `--dryrun`) and commit `VERSION.md` with the same PR. Do **not** have CI auto-commit it — that creates a push loop.
Customise the path with `--version-file PATH`, or skip writing with `--no-version-file`.
## Using locally
```bash
# Preview what the next build would be, without side effects
semver-ci --dryrun
# Emit just the version string (useful for scripting)
semver-ci --build 42 --no-version-file
# Produce a dev build with an explicit branch name
semver-ci --dryrun --branch feature/auth --build 77
```
## Using in Gitea Actions
The supplied workflow template installs `semver-ci` from the small-scripts repo, runs it with `--release`, and uploads `VERSION.md` as an artifact. The relevant step:
```yaml
- name: Compute version & release
id: ver
run: semver-ci --release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
```
Downstream steps can read the outputs:
```yaml
- name: Build container
run: |
docker build -t myapp:${{ steps.ver.outputs.version }} .
docker push myapp:${{ steps.ver.outputs.version }}
```
Outputs emitted to `$GITHUB_OUTPUT`:
| Name | Example | Notes |
|------|---------|-------|
| `version` | `1.4.2.317` or `1.4.2.317-feature-x-abc1234` | always set |
| `tag` | `v1.4.2.317` | empty on non-main branches |
| `released` | `true` / `false` | true when M/m/p actually changed |
| `branch` | `main` / `feature/x` | raw branch name (no `/` → `-` substitution) |
| `is_main` | `true` / `false` | convenience flag for `if:` conditions |
## Flag reference
| Flag | Default | Purpose |
|------|---------|---------|
| `-n`, `--dryrun` | off | Preview; no side effects |
| `--tag` | off | Create the tag locally (main-only) |
| `--push` | off | Also push tag to `origin` (implies `--tag`, main-only) |
| `--release` | off | Also POST a Gitea release (implies `--push`, main-only, requires M/m/p change) |
| `--build N` | env → git count | Override BUILD |
| `--base TAG` | auto | Override last-release tag detection |
| `--branch NAME` | env → git | Override current-branch detection |
| `--main-branch NAME` | `main` | Which branch is considered "main" |
| `--version-file PATH` | `<repo>/VERSION.md` | Where to write the version file |
| `--no-version-file` | off | Skip writing the version file |
## Troubleshooting
**"Cannot determine BUILD number"** — you're not in CI and the repo has no commits yet, or `git rev-list --count HEAD` failed. Pass `--build N` explicitly.
**Release not created even though I pushed `NEW_PATCH`** — releases happen only on the main branch. Verify `steps.ver.outputs.is_main == 'true'` and `released == 'true'` in the workflow log, or re-run locally with `--dryrun` to see what `semver-ci` detected.
**Tag already exists** — someone else (or a previous run) already created the tag. If you rebuilt the same commit, the resulting `BUILD` will be the same and the tag will collide. Either bump BUILD (re-run the workflow — `GITHUB_RUN_NUMBER` will be higher) or delete the conflicting tag if it was mistaken.
**Branch push created a release anyway** — double-check `--main-branch`. If your main branch isn't named `main`, pass `--main-branch master` (or whatever) in the workflow step.
**Gitea release API 401/403** — the `GITEA_TOKEN` secret is missing or lacks repo-write scope. Mint a new token in User Settings → Applications and grant `write:repository`.
**`sort -V` picked the wrong tag** — only tags matching `^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$` are considered. Older tags in other formats are ignored, which is usually what you want. If you need to pin a specific base, use `--base vX.Y.Z.B`.