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

View File

@@ -24,6 +24,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent-
| `sync-repos` | Mirror git repos across remotes | Done | | `sync-repos` | Mirror git repos across remotes | Done |
| `unreflected-logs` | Scan projects for unreflected session logs | Done | | `unreflected-logs` | Scan projects for unreflected session logs | Done |
| `validate-skill` | Validate SKILL.md files against known Claude Code restrictions | Done | | `validate-skill` | Validate SKILL.md files against known Claude Code restrictions | Done |
| `semver-ci` | Compute `MAJOR.MINOR.PATCH.BUILD` from commit-message tokens, maintain `VERSION.md`, and (on main) tag/push/create a Gitea release — see [`docs/semver-ci.md`](docs/semver-ci.md) and template at [`templates/gitea-workflow-version.yml`](templates/gitea-workflow-version.yml) | Done |
### Engagement modes (claude-profile) ### Engagement modes (claude-profile)

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`.

341
scripts/semver-ci Executable file
View File

@@ -0,0 +1,341 @@
#!/usr/bin/env bash
set -uo pipefail
usage() {
cat <<'EOF'
Usage: semver-ci [OPTIONS]
Compute the next MAJOR.MINOR.PATCH.BUILD from commit-message tokens since the
last release, maintain a VERSION.md file, and optionally tag / push / create a
Gitea release. Non-main branches get MAJOR.MINOR.PATCH.BUILD-BRANCH-SHORTSHA
and never produce a release.
Options:
-n, --dryrun Preview actions without side effects
--tag Create the tag locally (main-only)
--push Push the tag to origin (implies --tag, main-only)
--release Create Gitea release via API (implies --push, main-only)
--build N Override BUILD number
--base TAG Override last-release tag auto-detection
--branch NAME Override current-branch detection
--main-branch NAME Name of the main branch (default: main)
--version-file PATH Path to the version file (default: <repo>/VERSION.md)
--no-version-file Skip writing the version file
-h, --help Show this help
Environment (read when present):
GITHUB_RUN_NUMBER / GITEA_RUN_NUMBER BUILD source
GITHUB_REF_NAME Branch name
GITHUB_SHA Full commit SHA
GITHUB_OUTPUT Step outputs file (append-mode)
GITEA_TOKEN / GITHUB_TOKEN Auth for --release
GITHUB_SERVER_URL / GITHUB_REPOSITORY Target server and repo for --release
EOF
}
dryrun=false
do_tag=false
do_push=false
do_release=false
build_override=""
base_override=""
branch_override=""
main_branch="main"
version_file_override=""
write_version_file=true
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-n|--dryrun) dryrun=true; shift ;;
--tag) do_tag=true; shift ;;
--push) do_tag=true; do_push=true; shift ;;
--release) do_tag=true; do_push=true; do_release=true; shift ;;
--build)
[[ $# -lt 2 ]] && { echo "Error: --build requires a value" >&2; exit 1; }
build_override="$2"; shift 2 ;;
--base)
[[ $# -lt 2 ]] && { echo "Error: --base requires a value" >&2; exit 1; }
base_override="$2"; shift 2 ;;
--branch)
[[ $# -lt 2 ]] && { echo "Error: --branch requires a value" >&2; exit 1; }
branch_override="$2"; shift 2 ;;
--main-branch)
[[ $# -lt 2 ]] && { echo "Error: --main-branch requires a value" >&2; exit 1; }
main_branch="$2"; shift 2 ;;
--version-file)
[[ $# -lt 2 ]] && { echo "Error: --version-file requires a value" >&2; exit 1; }
version_file_override="$2"; shift 2 ;;
--no-version-file) write_version_file=false; shift ;;
-*) echo "Error: Unknown option: $1" >&2; exit 1 ;;
*) echo "Error: Unexpected argument: $1" >&2; exit 1 ;;
esac
done
# --- git repo sanity ---
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "Error: not inside a git repository" >&2
exit 1
fi
if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
echo "Error: repository has no commits" >&2
exit 1
fi
repo_root="$(git rev-parse --show-toplevel)"
# --- branch & commit detection ---
if [[ -n "$branch_override" ]]; then
branch="$branch_override"
elif [[ -n "${GITHUB_REF_NAME:-}" ]]; then
branch="$GITHUB_REF_NAME"
else
branch="$(git rev-parse --abbrev-ref HEAD)"
fi
if [[ -n "${GITHUB_SHA:-}" ]]; then
short_sha="${GITHUB_SHA:0:7}"
else
short_sha="$(git rev-parse --short=7 HEAD)"
fi
is_main=false
if [[ "$branch" == "$main_branch" ]]; then
is_main=true
fi
# --- last release tag discovery ---
TAG_RE='^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
if [[ -n "$base_override" ]]; then
if ! git rev-parse --verify "$base_override" >/dev/null 2>&1; then
echo "Error: --base tag does not exist: $base_override" >&2
exit 1
fi
last_tag="$base_override"
else
last_tag="$(git tag -l 'v*' | grep -E "$TAG_RE" | sort -V | tail -n 1 || true)"
fi
if [[ -z "$last_tag" ]]; then
maj=0; min=0; pat=0
log_range="HEAD"
else
ver="${last_tag#v}"
IFS='.' read -r maj min pat _build <<< "$ver"
log_range="${last_tag}..HEAD"
fi
# --- commit scanning ---
commits="$(git log "$log_range" --format=%B 2>/dev/null || true)"
commit_count="$(git log "$log_range" --oneline 2>/dev/null | wc -l | tr -d ' ' || true)"
commit_count="${commit_count:-0}"
count_trigger() {
local token="$1"
# Whole-line match, whitespace-tolerant; count matching lines.
printf '%s\n' "$commits" | grep -cE "^[[:space:]]*${token}[[:space:]]*\$" || true
}
count_major="$(count_trigger NEW_MAJOR)"
count_minor="$(count_trigger NEW_MINOR)"
count_patch="$(count_trigger NEW_PATCH)"
bump="none"
released=false
if [[ "$count_major" -gt 0 ]]; then
maj=$((maj + 1)); min=0; pat=0
bump="MAJOR"; released=true
elif [[ "$count_minor" -gt 0 ]]; then
min=$((min + 1)); pat=0
bump="MINOR"; released=true
elif [[ "$count_patch" -gt 0 ]]; then
pat=$((pat + 1))
bump="PATCH"; released=true
fi
# --- BUILD number ---
if [[ -n "$build_override" ]]; then
build="$build_override"
elif [[ -n "${GITHUB_RUN_NUMBER:-}" ]]; then
build="$GITHUB_RUN_NUMBER"
elif [[ -n "${GITEA_RUN_NUMBER:-}" ]]; then
build="$GITEA_RUN_NUMBER"
else
build="$(git rev-list --count HEAD 2>/dev/null || echo "")"
fi
if [[ -z "$build" ]]; then
echo "Error: Cannot determine BUILD number. Set GITHUB_RUN_NUMBER or pass --build N." >&2
exit 1
fi
if ! [[ "$build" =~ ^[0-9]+$ ]]; then
echo "Error: BUILD number must be a non-negative integer, got: $build" >&2
exit 1
fi
# --- compose version ---
if $is_main; then
version="${maj}.${min}.${pat}.${build}"
new_tag="v${version}"
else
safe_branch="${branch//\//-}"
version="${maj}.${min}.${pat}.${build}-${safe_branch}-${short_sha}"
new_tag=""
fi
# --- version file path ---
if [[ -n "$version_file_override" ]]; then
version_file="$version_file_override"
else
version_file="${repo_root}/VERSION.md"
fi
generated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
render_version_file() {
cat <<EOF
# Version
**${version}**
- Major: ${maj}
- Minor: ${min}
- Patch: ${pat}
- Build: ${build}
- Branch: ${branch}
- Commit: ${short_sha}
- Tag: ${new_tag:--}
- Generated: ${generated}
EOF
}
# --- dryrun output ---
if $dryrun; then
if $is_main; then
echo "[dryrun] Branch: ${branch} (main-branch detected)"
else
echo "[dryrun] Branch: ${branch} (non-main)"
fi
echo "[dryrun] Commit: ${short_sha}"
echo "[dryrun] Last release: ${last_tag:-<none>}"
echo "[dryrun] Commits scanned: ${commit_count}"
triggers=()
[[ "$count_major" -gt 0 ]] && triggers+=("NEW_MAJOR (${count_major})")
[[ "$count_minor" -gt 0 ]] && triggers+=("NEW_MINOR (${count_minor})")
[[ "$count_patch" -gt 0 ]] && triggers+=("NEW_PATCH (${count_patch})")
if [[ ${#triggers[@]} -eq 0 ]]; then
echo "[dryrun] Triggers found: none"
else
echo "[dryrun] Triggers found: $(IFS=', '; echo "${triggers[*]}")"
fi
echo "[dryrun] Version bump: ${bump}"
echo "[dryrun] New version: ${version}"
if $write_version_file; then
echo "[dryrun] Would write version file: ${version_file}"
fi
if $is_main; then
$do_tag && echo "[dryrun] Would create tag: ${new_tag}"
$do_push && echo "[dryrun] Would push tag to origin"
if $do_release; then
if $released; then
echo "[dryrun] Would create Gitea release for ${new_tag}"
else
echo "[dryrun] Would skip release (no MAJOR/MINOR/PATCH change)"
fi
fi
else
if $do_tag || $do_push || $do_release; then
echo "[dryrun] Would skip tag/push/release on non-main branch"
fi
fi
exit 0
fi
# --- real mode ---
echo "$version"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "version=${version}"
echo "tag=${new_tag}"
echo "released=${released}"
echo "branch=${branch}"
echo "is_main=${is_main}"
} >> "$GITHUB_OUTPUT"
fi
if $write_version_file; then
vf_dir="$(dirname "$version_file")"
if [[ ! -d "$vf_dir" ]]; then
echo "Error: version file directory does not exist: $vf_dir" >&2
exit 1
fi
render_version_file > "$version_file"
fi
if ! $is_main; then
if $do_tag || $do_push || $do_release; then
echo "semver-ci: skipping tag/push/release on non-main branch '${branch}'" >&2
fi
exit 0
fi
if $do_tag; then
if git rev-parse -q --verify "refs/tags/${new_tag}" >/dev/null; then
echo "Error: tag already exists: ${new_tag}" >&2
exit 1
fi
git tag "$new_tag"
fi
if $do_push; then
git push origin "$new_tag"
fi
if $do_release; then
if ! $released; then
echo "semver-ci: release skipped (no MAJOR/MINOR/PATCH change)" >&2
exit 0
fi
token="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "$token" ]]; then
echo "Error: GITEA_TOKEN or GITHUB_TOKEN required for --release" >&2
exit 1
fi
server="${GITHUB_SERVER_URL:-}"
repo="${GITHUB_REPOSITORY:-}"
if [[ -z "$server" || -z "$repo" ]]; then
echo "Error: GITHUB_SERVER_URL and GITHUB_REPOSITORY must be set for --release" >&2
exit 1
fi
api_url="${server%/}/api/v1/repos/${repo}/releases"
body="$(printf '{"tag_name":"%s","name":"%s","body":"Automated release"}' "$new_tag" "$new_tag")"
tmp_response="$(mktemp)"
trap 'rm -f "$tmp_response"' EXIT
http_code="$(curl -sS -o "$tmp_response" -w '%{http_code}' \
-X POST "$api_url" \
-H "Authorization: token ${token}" \
-H "Content-Type: application/json" \
-d "$body")" || { echo "Error: curl failed" >&2; exit 1; }
if [[ "$http_code" != "201" ]]; then
echo "Error: Gitea release creation failed (HTTP ${http_code})" >&2
cat "$tmp_response" >&2
exit 1
fi
fi

227
specs/semver-ci.spec.md Normal file
View 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.

View File

@@ -0,0 +1,72 @@
# .gitea/workflows/version.yml
#
# Reusable Gitea Actions workflow that:
# - computes the next semantic version from commit-message tokens
# - writes VERSION.md in the repo root
# - on the main branch, creates a git tag and a Gitea release when
# MAJOR, MINOR, or PATCH actually changed
#
# Drop this file in at `.gitea/workflows/version.yml` and edit the two
# values in the `Install semver-ci` step that point at your small-scripts
# repo if they differ from the defaults.
#
# Requirements:
# - Gitea Actions enabled for the repo
# - A `GITEA_TOKEN` secret with write access to the repo (used for
# creating the release). `GITHUB_TOKEN` is also provided automatically
# by Gitea Actions and is accepted as a fallback.
# - Commit triggers: put NEW_MAJOR, NEW_MINOR, or NEW_PATCH on a line
# of its own in a commit message to bump the corresponding part.
name: version
on:
push:
# Don't re-trigger when we push the tag we just created.
tags-ignore:
- 'v*'
jobs:
version:
runs-on: ubuntu-latest
steps:
- name: Checkout (full history for tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install semver-ci
env:
# Edit these two if small-scripts lives elsewhere.
SMALL_SCRIPTS_REPO: skynet/small-scripts
SMALL_SCRIPTS_REF: main
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
auth=()
[ -n "${GITEA_TOKEN:-}" ] && auth=(-H "Authorization: token ${GITEA_TOKEN}")
curl -fsSL "${auth[@]}" \
"${GITHUB_SERVER_URL%/}/${SMALL_SCRIPTS_REPO}/raw/branch/${SMALL_SCRIPTS_REF}/scripts/semver-ci" \
-o /usr/local/bin/semver-ci
chmod +x /usr/local/bin/semver-ci
- name: Compute version & release
id: ver
run: semver-ci --release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Upload VERSION.md
uses: actions/upload-artifact@v4
with:
name: version
path: VERSION.md
if-no-files-found: warn
- name: Print summary
run: |
echo "Version: ${{ steps.ver.outputs.version }}"
echo "Tag: ${{ steps.ver.outputs.tag }}"
echo "Branch: ${{ steps.ver.outputs.branch }}"
echo "Is main: ${{ steps.ver.outputs.is_main }}"
echo "Released: ${{ steps.ver.outputs.released }}"

439
tests/test-semver-ci.sh Executable file
View File

@@ -0,0 +1,439 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SEMVER="$SCRIPT_DIR/scripts/semver-ci"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass=0
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected: $expected"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_match() {
local desc="$1" pattern="$2" actual="$3"
if [[ "$actual" =~ $pattern ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " pattern: $pattern"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_no_match() {
local desc="$1" pattern="$2" actual="$3"
if [[ ! "$actual" =~ $pattern ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " should not match: $pattern"
echo " actual: $actual"
((fail++)) || true
fi
}
assert_exit() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" -eq "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected exit: $expected"
echo " actual exit: $actual"
((fail++)) || true
fi
}
make_repo() {
local dir="$1"
mkdir -p "$dir"
(
cd "$dir"
git init -q -b main
git config user.email "t@t"
git config user.name "t"
git commit --allow-empty -q -m "init"
)
}
commit_msg() {
local dir="$1" msg="$2"
(cd "$dir" && git commit --allow-empty -q -m "$msg")
}
tag_at() {
local dir="$1" tag="$2"
(cd "$dir" && git tag "$tag")
}
run_in() {
local dir="$1"; shift
(cd "$dir" && "$SEMVER" "$@" 2>&1)
}
echo "=== semver-ci tests ==="
echo
# --- Help ---
out=$("$SEMVER" --help 2>&1)
rc=$?
assert_exit "help exits 0" 0 "$rc"
assert_match "help mentions BUILD" "BUILD" "$out"
assert_match "help mentions VERSION.md" "VERSION.md" "$out"
# --- Outside git repo ---
tmp=$(mktemp -d)
out=$(cd "$tmp" && "$SEMVER" --dryrun 2>&1)
rc=$?
assert_exit "outside git repo exits 1" 1 "$rc"
assert_match "outside git repo error" "not inside a git repository" "$out"
rm -rf "$tmp"
# --- Fresh repo, no triggers (main branch) ---
tmp=$(mktemp -d)
make_repo "$tmp"
out=$(run_in "$tmp" --dryrun --build 42)
assert_match "fresh repo: version 0.0.0.42" "New version: 0\.0\.0\.42" "$out"
assert_match "fresh repo: bump none" "Version bump: none" "$out"
assert_match "fresh repo: triggers none" "Triggers found: none" "$out"
assert_match "fresh repo: last release none" "Last release: <none>" "$out"
assert_match "fresh repo: branch main detected" "Branch: main \(main-branch detected\)" "$out"
rm -rf "$tmp"
# --- NEW_PATCH triggers patch bump ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'fix: stuff\n\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 10)
assert_match "NEW_PATCH: version 0.0.1.10" "New version: 0\.0\.1\.10" "$out"
assert_match "NEW_PATCH: bump PATCH" "Version bump: PATCH" "$out"
assert_match "NEW_PATCH: trigger counted" "NEW_PATCH \(1\)" "$out"
rm -rf "$tmp"
# --- NEW_MINOR triggers minor bump and resets patch ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'feat: thing\n\nNEW_MINOR'
out=$(run_in "$tmp" --dryrun --build 10)
assert_match "NEW_MINOR: version 0.1.0.10" "New version: 0\.1\.0\.10" "$out"
assert_match "NEW_MINOR: bump MINOR" "Version bump: MINOR" "$out"
rm -rf "$tmp"
# --- NEW_MAJOR triggers major bump and resets minor/patch ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'feat!: breaking\n\nNEW_MAJOR'
out=$(run_in "$tmp" --dryrun --build 10)
assert_match "NEW_MAJOR: version 1.0.0.10" "New version: 1\.0\.0\.10" "$out"
assert_match "NEW_MAJOR: bump MAJOR" "Version bump: MAJOR" "$out"
rm -rf "$tmp"
# --- Precedence: MAJOR > MINOR > PATCH ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'mixed\n\nNEW_MINOR\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 1)
assert_match "minor beats patch" "Version bump: MINOR" "$out"
rm -rf "$tmp"
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'big\n\nNEW_MAJOR\nNEW_MINOR\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 1)
assert_match "major beats minor and patch" "Version bump: MAJOR" "$out"
rm -rf "$tmp"
# --- Inline mentions do NOT trigger ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" "refactor: mention NEW_PATCH inline but not alone"
out=$(run_in "$tmp" --dryrun --build 1)
assert_match "inline NEW_PATCH ignored: bump none" "Version bump: none" "$out"
assert_match "inline NEW_PATCH ignored: triggers none" "Triggers found: none" "$out"
rm -rf "$tmp"
# --- Whitespace-tolerant triggers ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'spaced\n\n NEW_PATCH '
out=$(run_in "$tmp" --dryrun --build 1)
assert_match "whitespace-padded trigger counts" "Version bump: PATCH" "$out"
rm -rf "$tmp"
# --- Base from prior tag: PATCH bump ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'seed\n\nNEW_MINOR'
tag_at "$tmp" "v1.2.3.50"
commit_msg "$tmp" $'hotfix\n\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 88)
assert_match "prior v1.2.3.50 + NEW_PATCH = 1.2.4.88" "New version: 1\.2\.4\.88" "$out"
assert_match "prior tag detected" "Last release: v1\.2\.3\.50" "$out"
rm -rf "$tmp"
# --- MAJOR bump resets MINOR/PATCH from prior tag ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" "seed"
tag_at "$tmp" "v3.5.7.99"
commit_msg "$tmp" $'breaking\n\nNEW_MAJOR'
out=$(run_in "$tmp" --dryrun --build 100)
assert_match "prior v3.5.7.99 + NEW_MAJOR = 4.0.0.100" "New version: 4\.0\.0\.100" "$out"
rm -rf "$tmp"
# --- No triggers, prior tag: M/m/p unchanged ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" "seed"
tag_at "$tmp" "v2.4.8.60"
commit_msg "$tmp" "chore: tidy"
out=$(run_in "$tmp" --dryrun --build 77)
assert_match "no trigger: reuses M/m/p" "New version: 2\.4\.8\.77" "$out"
assert_match "no trigger: bump none" "Version bump: none" "$out"
rm -rf "$tmp"
# --- Tag selection: latest by -V ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" "c1"; tag_at "$tmp" "v1.0.0.1"
commit_msg "$tmp" "c2"; tag_at "$tmp" "v10.2.0.5" # 10 > 2 numerically
commit_msg "$tmp" "c3"; tag_at "$tmp" "v2.9.9.9"
commit_msg "$tmp" $'next\n\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 1)
assert_match "sort -V picks v10.2.0.5 as latest" "Last release: v10\.2\.0\.5" "$out"
assert_match "bump from v10.2.0.5" "New version: 10\.2\.1\.1" "$out"
rm -rf "$tmp"
# --- --base overrides detection ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" "c1"; tag_at "$tmp" "v5.0.0.1"
commit_msg "$tmp" "c2"; tag_at "$tmp" "v6.0.0.2"
commit_msg "$tmp" $'n\n\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 9 --base v5.0.0.1)
assert_match "--base honoured" "Last release: v5\.0\.0\.1" "$out"
assert_match "--base affects bump base" "New version: 5\.0\.1\.9" "$out"
rm -rf "$tmp"
# --- --base to nonexistent tag errors ---
tmp=$(mktemp -d)
make_repo "$tmp"
out=$(run_in "$tmp" --dryrun --base v99.99.99.99 2>&1)
rc=$?
assert_exit "--base nonexistent exits 1" 1 "$rc"
assert_match "--base nonexistent error" "does not exist" "$out"
rm -rf "$tmp"
# --- --build validation ---
tmp=$(mktemp -d)
make_repo "$tmp"
out=$(run_in "$tmp" --dryrun --build abc 2>&1)
rc=$?
assert_exit "--build non-numeric exits 1" 1 "$rc"
assert_match "--build non-numeric error" "non-negative integer" "$out"
rm -rf "$tmp"
# --- Env-based BUILD ---
tmp=$(mktemp -d)
make_repo "$tmp"
out=$(cd "$tmp" && GITHUB_RUN_NUMBER=555 "$SEMVER" --dryrun 2>&1)
assert_match "GITHUB_RUN_NUMBER used for BUILD" "New version: 0\.0\.0\.555" "$out"
out=$(cd "$tmp" && GITEA_RUN_NUMBER=777 "$SEMVER" --dryrun 2>&1)
assert_match "GITEA_RUN_NUMBER used for BUILD" "New version: 0\.0\.0\.777" "$out"
out=$(cd "$tmp" && GITHUB_RUN_NUMBER=100 GITEA_RUN_NUMBER=200 "$SEMVER" --dryrun 2>&1)
assert_match "GITHUB_RUN_NUMBER preferred over GITEA_RUN_NUMBER" "New version: 0\.0\.0\.100" "$out"
rm -rf "$tmp"
# --- Branch build: non-main ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" "seed"; tag_at "$tmp" "v1.2.3.10"
(cd "$tmp" && git checkout -q -b feature/login)
commit_msg "$tmp" $'wip\n\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --build 77)
assert_match "non-main version has branch+sha suffix" "New version: 1\.2\.4\.77-feature-login-[0-9a-f]{7}" "$out"
assert_match "non-main shows non-main label" "Branch: feature/login \(non-main\)" "$out"
rm -rf "$tmp"
# --- --branch override ---
tmp=$(mktemp -d)
make_repo "$tmp"
out=$(run_in "$tmp" --dryrun --build 5 --branch bugfix/oops)
assert_match "--branch override labelled non-main" "Branch: bugfix/oops \(non-main\)" "$out"
assert_match "--branch override in version" "New version: 0\.0\.0\.5-bugfix-oops-[0-9a-f]{7}" "$out"
rm -rf "$tmp"
# --- --main-branch override ---
tmp=$(mktemp -d)
make_repo "$tmp"
(cd "$tmp" && git checkout -q -b trunk)
out=$(run_in "$tmp" --dryrun --build 1 --main-branch trunk)
assert_match "--main-branch override treats trunk as main" "Branch: trunk \(main-branch detected\)" "$out"
assert_no_match "--main-branch override: no branch suffix" "\-trunk\-" "$out"
rm -rf "$tmp"
# --- Non-main skips tag/push/release in dryrun ---
tmp=$(mktemp -d)
make_repo "$tmp"
(cd "$tmp" && git checkout -q -b feature/x)
commit_msg "$tmp" $'wip\n\nNEW_PATCH'
out=$(run_in "$tmp" --dryrun --release --build 1)
assert_match "non-main with --release: dryrun skip message" "Would skip tag/push/release on non-main branch" "$out"
assert_no_match "non-main with --release: no create tag line" "Would create tag" "$out"
rm -rf "$tmp"
# --- Real mode: version file written ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'feat\n\nNEW_MINOR'
out=$(run_in "$tmp" --build 42)
rc=$?
assert_exit "real-mode exits 0" 0 "$rc"
assert_eq "stdout is just the version" "0.1.0.42" "$out"
assert_match "VERSION.md created" "0.1.0.42" "$(cat "$tmp/VERSION.md")"
assert_match "VERSION.md has Major line" "Major: 0" "$(cat "$tmp/VERSION.md")"
assert_match "VERSION.md has Build line" "Build: 42" "$(cat "$tmp/VERSION.md")"
assert_match "VERSION.md has Tag line" "Tag: v0.1.0.42" "$(cat "$tmp/VERSION.md")"
rm -rf "$tmp"
# --- Real mode: non-main VERSION.md shows branch suffix and no tag ---
tmp=$(mktemp -d)
make_repo "$tmp"
(cd "$tmp" && git checkout -q -b dev/x)
commit_msg "$tmp" $'wip\n\nNEW_PATCH'
out=$(run_in "$tmp" --build 7)
assert_match "non-main stdout has suffix" "0\.0\.1\.7-dev-x-[0-9a-f]{7}" "$out"
assert_match "non-main VERSION.md Tag is dash" "Tag: -" "$(cat "$tmp/VERSION.md")"
assert_match "non-main VERSION.md branch preserved" "Branch: dev/x" "$(cat "$tmp/VERSION.md")"
rm -rf "$tmp"
# --- --no-version-file skips file write ---
tmp=$(mktemp -d)
make_repo "$tmp"
run_in "$tmp" --build 1 --no-version-file >/dev/null
if [[ ! -e "$tmp/VERSION.md" ]]; then
echo -e "${GREEN}PASS${NC}: --no-version-file skips write"
((pass++)) || true
else
echo -e "${RED}FAIL${NC}: --no-version-file skips write"
((fail++)) || true
fi
rm -rf "$tmp"
# --- Real mode: --tag creates the tag on main ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'feat\n\nNEW_PATCH'
run_in "$tmp" --tag --build 3 >/dev/null
tag_present=$(cd "$tmp" && git tag -l "v0.0.1.3")
assert_eq "--tag on main creates tag" "v0.0.1.3" "$tag_present"
rm -rf "$tmp"
# --- Real mode: --tag is skipped on non-main ---
tmp=$(mktemp -d)
make_repo "$tmp"
(cd "$tmp" && git checkout -q -b feature/a)
commit_msg "$tmp" $'w\n\nNEW_PATCH'
run_in "$tmp" --tag --build 3 >/dev/null 2>&1
tag_present=$(cd "$tmp" && git tag -l "v0.0.1.3")
assert_eq "--tag on non-main does NOT create tag" "" "$tag_present"
rm -rf "$tmp"
# --- Tag conflict on main errors ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'feat\n\nNEW_PATCH'
tag_at "$tmp" "v0.0.1.3"
out=$(run_in "$tmp" --tag --build 3 2>&1)
rc=$?
assert_exit "tag conflict exits 1" 1 "$rc"
assert_match "tag conflict error message" "tag already exists" "$out"
rm -rf "$tmp"
# --- GITHUB_OUTPUT is populated ---
tmp=$(mktemp -d)
make_repo "$tmp"
commit_msg "$tmp" $'feat\n\nNEW_MINOR'
gh_out="$tmp/.outputs"
: > "$gh_out"
(cd "$tmp" && GITHUB_OUTPUT="$gh_out" "$SEMVER" --build 9 >/dev/null)
content="$(cat "$gh_out")"
assert_match "GITHUB_OUTPUT has version" "version=0\.1\.0\.9" "$content"
assert_match "GITHUB_OUTPUT has tag" "tag=v0\.1\.0\.9" "$content"
assert_match "GITHUB_OUTPUT has released=true" "released=true" "$content"
assert_match "GITHUB_OUTPUT has branch=main" "branch=main" "$content"
assert_match "GITHUB_OUTPUT has is_main=true" "is_main=true" "$content"
rm -rf "$tmp"
# --- GITHUB_OUTPUT on non-main: tag empty, is_main false ---
tmp=$(mktemp -d)
make_repo "$tmp"
(cd "$tmp" && git checkout -q -b feature/z)
commit_msg "$tmp" $'w\n\nNEW_PATCH'
gh_out="$tmp/.outputs"
: > "$gh_out"
(cd "$tmp" && GITHUB_OUTPUT="$gh_out" "$SEMVER" --build 4 >/dev/null)
content="$(cat "$gh_out")"
assert_match "non-main GITHUB_OUTPUT: is_main=false" "is_main=false" "$content"
assert_match "non-main GITHUB_OUTPUT: tag empty" "^tag=$" "$(printf '%s\n' "$content" | grep '^tag=')"
rm -rf "$tmp"
# --- Summary ---
echo
total=$((pass + fail))
echo -e "Results: ${GREEN}${pass}${NC}/${total} passed"
if [[ $fail -gt 0 ]]; then
echo -e "${RED}${fail} test(s) failed${NC}"
exit 1
fi