diff --git a/README.md b/README.md index f12d6d7..2f35cd9 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent- | `sync-repos` | Mirror git repos across remotes | Done | | `unreflected-logs` | Scan projects for unreflected session logs | 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) diff --git a/docs/semver-ci.md b/docs/semver-ci.md new file mode 100644 index 0000000..dd30e73 --- /dev/null +++ b/docs/semver-ci.md @@ -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.` (or higher, depending on the trigger you used) and a release tagged `v0.0.1.`. + +## 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` | `/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`. diff --git a/scripts/semver-ci b/scripts/semver-ci new file mode 100755 index 0000000..dd44cb0 --- /dev/null +++ b/scripts/semver-ci @@ -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: /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 <}" + 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 diff --git a/specs/semver-ci.spec.md b/specs/semver-ci.spec.md new file mode 100644 index 0000000..0058bae --- /dev/null +++ b/specs/semver-ci.spec.md @@ -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: `/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 ..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= + tag= + released= + branch= + is_main= + ``` +14. Write the version file (unless `--no-version-file`): + - Default path: `/VERSION.md`. Path may be overridden with `--version-file`. + - Content (markdown): + ``` + # Version + + **** + + - Major: + - Minor: + - Patch: + - Build: + - Branch: + - Commit: + - Tag: + - Generated: + ``` +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 ` (error if tag already exists). + - If `--push`: `git push origin `. + - 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 `/api/v1/repos//releases` with `{ "tag_name": "", "name": "", "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 `` 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. diff --git a/templates/gitea-workflow-version.yml b/templates/gitea-workflow-version.yml new file mode 100644 index 0000000..0790250 --- /dev/null +++ b/templates/gitea-workflow-version.yml @@ -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 }}" diff --git a/tests/test-semver-ci.sh b/tests/test-semver-ci.sh new file mode 100755 index 0000000..343002d --- /dev/null +++ b/tests/test-semver-ci.sh @@ -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: " "$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