From 30c72976c1ac89e09788e799dac3aa4571871203 Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Sat, 30 May 2026 10:46:01 +1000
Subject: [PATCH] feat: add release versioning

---
 AGENTS.md             |   5 +-
 CHANGELOG.md          |  56 ++++++++++++++++
 README.md             |  29 +++++++-
 VERSION               |   1 +
 cliff.toml            |  36 ++++++++++
 devloop               |  25 ++++++-
 release.sh            | 151 ++++++++++++++++++++++++++++++++++++++++++
 tests/devloop_test.sh |  28 +++++++-
 8 files changed, 326 insertions(+), 5 deletions(-)
 create mode 100644 CHANGELOG.md
 create mode 100644 VERSION
 create mode 100644 cliff.toml
 create mode 100755 release.sh

diff --git a/AGENTS.md b/AGENTS.md
index c54c24d..d18eacc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,13 +2,14 @@
 
 ## Project Structure & Module Organization
 
-This is a Bash CLI project. The active runtime is the root `devloop` executable. `install.sh` links it into a local bin directory and installs bundled skills into `~/.agents/skills`, `tests/devloop_test.sh` covers the shell runtime, `skills/devloop-spec/SKILL.md` is the spec-generation skill, `skills/devloop-review/SKILL.md` is the review skill, and `skills/devloop-spec/references/spec-template.md` is the starter spec. Generated runtime output belongs under `.codex/` in target repositories and should not be committed here.
+This is a Bash CLI project. The active runtime is the root `devloop` executable. `VERSION` is the single version source, `release.sh` cuts local release commits and annotated tags, `install.sh` links the CLI into a local bin directory and installs bundled skills into `~/.agents/skills` and `~/.claude/skills`, `tests/devloop_test.sh` covers the shell runtime, `skills/devloop-spec/SKILL.md` is the spec-generation skill, `skills/devloop-review/SKILL.md` is the review skill, and `skills/devloop-spec/references/spec-template.md` is the starter spec. Generated runtime output belongs under `.codex/` in target repositories and should not be committed here.
 
 ## Build, Test, and Development Commands
 
 - `bash tests/devloop_test.sh`: run the shell test suite.
 - `./install.sh`: link `devloop` into `~/.local/bin` or `DEVLOOP_BIN_DIR`.
 - `./devloop --plain .specs/change.md`: example local CLI invocation from a target git worktree.
+- `./release.sh 0.1.0 --dry-run`: validate the release path without changing files.
 
 ## Coding Style & Naming Conventions
 
@@ -24,4 +25,4 @@ Git history follows Conventional Commits, for example `fix: surface devloop comm
 
 ## Agent-Specific Instructions
 
-Keep changes narrow and regression-first. Do not commit `.codex/`, coverage artifacts, local specs, or dependency caches unless explicitly requested. When modifying acceptance or reporting behavior, update both tests and `README.md` examples if user-facing output changes.
+Keep changes narrow and regression-first. Do not commit `.codex/`, coverage artifacts, local specs, or dependency caches unless explicitly requested. When modifying acceptance, reporting, versioning, or release behavior, update tests and `README.md` examples if user-facing output changes.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f6a76b2
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,56 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [0.1.0](https://github.com/satyaborg/devloop/releases/tag/v0.1.0) - 2026-05-30
+
+### Added
+
+- initial implementation ([96901ee](https://github.com/satyaborg/devloop/commit/96901ee67cbe8e01e4fe25cea9807d100dc94f59))
+- reuse agent sessions in devloop ([6cf72c5](https://github.com/satyaborg/devloop/commit/6cf72c59df7acf8ae95e66e882e7e05898365abd))
+- port devloop to bun opentui ([38cf9bd](https://github.com/satyaborg/devloop/commit/38cf9bde799ec7e8c5b47b261e0ff986a14dd67f))
+- show default cli welcome ([4e55181](https://github.com/satyaborg/devloop/commit/4e551816a2446ac041100b22c988eaa0e3e5a398))
+- run devloop in isolated worktrees ([17b7fd5](https://github.com/satyaborg/devloop/commit/17b7fd5ac76ee90feb601efd11ac4b902bd73c99))
+- derive semantic work names ([0bb4bf0](https://github.com/satyaborg/devloop/commit/0bb4bf0411c854f872038c0ca22826582dd65b40))
+- show tui step progress ([fcfe52a](https://github.com/satyaborg/devloop/commit/fcfe52af09db5c6b6d3dbecb3e170a9849a860ac))
+- bundle spec generation skill ([a4ae43d](https://github.com/satyaborg/devloop/commit/a4ae43d45cb9e8e3cbfb01ebde03e94bf67fd3a2))
+- configure coder and reviewer agents ([f00c2ae](https://github.com/satyaborg/devloop/commit/f00c2aee76b55f15a98c806e20a527c0062420f5))
+- require evidence-rich review matrices ([1f31f70](https://github.com/satyaborg/devloop/commit/1f31f7077da79fcca2ec1219252c3d0e7ce37373))
+- automate pass commits and prs ([4c222a7](https://github.com/satyaborg/devloop/commit/4c222a72eaa73535777ed0dfbe789902f704589b))
+- npm-release-readiness ([057aef0](https://github.com/satyaborg/devloop/commit/057aef0fc8e715952c394af4be8ba3fc6ba59b1d))
+- force maximum agent effort ([51af634](https://github.com/satyaborg/devloop/commit/51af634d573774b6a88d722927f3460c4081d854))
+- add bash devloop runtime ([f6611e0](https://github.com/satyaborg/devloop/commit/f6611e00c480058014b73708ab7bfdae6eefc5cc))
+- add adversarial review gates ([5143efd](https://github.com/satyaborg/devloop/commit/5143efd46713e0b95b6e550d9e702fb2a814fba5))
+- overhaul interactive cli ui ([3d0f033](https://github.com/satyaborg/devloop/commit/3d0f0332592b1a68a4e3606249ca82013b7ba506))
+- configure spec directories ([c8a7cbd](https://github.com/satyaborg/devloop/commit/c8a7cbdc1ee272f8baae9e146a180d271d55de94))
+- add scoped devloop config ([00868e6](https://github.com/satyaborg/devloop/commit/00868e6532e87e1663340c187bee34872a2db38e))
+- simplify spec path settings ([1e1213c](https://github.com/satyaborg/devloop/commit/1e1213cafabe8f984e45ee8212e3585bd768d15f))
+- polish interactive tui ([a4485ae](https://github.com/satyaborg/devloop/commit/a4485ae3022a827275bed4870128a6ebeda2ff2c))
+
+
+### Breaking changes
+
+- install skills for codex and claude ([eb2cb6d](https://github.com/satyaborg/devloop/commit/eb2cb6de9f988ea2acac988f7f1fb782a25a39ba))
+
+
+### Documentation
+
+- add repository guidelines ([447dd0c](https://github.com/satyaborg/devloop/commit/447dd0ce63264b1fc9cc124eaa975fa179ee3e76))
+- link claude guide to agents ([70be06e](https://github.com/satyaborg/devloop/commit/70be06eebb326baaa54ff5eb4d8c6199f074d029))
+- clarify worktree cleanup ([3dc7b1b](https://github.com/satyaborg/devloop/commit/3dc7b1b239684e17683c04f8e0f3ba15c2d16b0e))
+- add license section ([ac82872](https://github.com/satyaborg/devloop/commit/ac8287290a2f7dae956248fc46411e9ac9cacf8b))
+
+
+### Fixed
+
+- surface devloop commit failures ([493031a](https://github.com/satyaborg/devloop/commit/493031ab90ba593ad2bc904ec3d8c0ba1fd92b96))
+- clarify worktree outputs ([6e1067a](https://github.com/satyaborg/devloop/commit/6e1067acf5cf0298a3e3c825176f190cbb346bbb))
+- clean up naming temp logs ([424a502](https://github.com/satyaborg/devloop/commit/424a502fe78441e6b9e9877f719bc876acbd2210))
+- align tui navigation labels ([3f74b90](https://github.com/satyaborg/devloop/commit/3f74b90b0d87294c5224f33888f6c05346ba1397))
+- derive report subtitles from specs ([1984ca6](https://github.com/satyaborg/devloop/commit/1984ca6b8e82dfdd3689df864fc1108d38ec2517))
+- add topical report haiku ([2cd843d](https://github.com/satyaborg/devloop/commit/2cd843df292217f676e59b3631d7f6d922d3a1db))
+- key sessions by configured agent ([16ef419](https://github.com/satyaborg/devloop/commit/16ef419de2429bc4569fc7789fb4fb1998aefc0f))
+- align release readiness tests ([8d9f0bf](https://github.com/satyaborg/devloop/commit/8d9f0bf14d5ca4be0096e01cca8ca19d140f270b))
+- harden bash runtime helpers ([7fb28cc](https://github.com/satyaborg/devloop/commit/7fb28cc7f7da3788d7ef3a233242465ad4a8e2c9))
+- preserve interactive run status ([48cadbb](https://github.com/satyaborg/devloop/commit/48cadbb140662919f6ae8ce6597274cf755d13a1))
+
diff --git a/README.md b/README.md
index 7b86d0a..d9e3a58 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,12 @@ cd devloop
 ./install.sh
 ```
 
+Check the installed version:
+
+```sh
+devloop --version
+```
+
 Run without installing:
 
 ```sh
@@ -104,6 +110,7 @@ devloop [options] <spec.md> [max=5]
 | `--no-strict` | Weaken strict review gates |
 | `--no-shell`, `--stay` | Do not open a shell in the generated worktree after completion |
 | `--shell`, `--enter-worktree` | Open a shell in the generated worktree after completion |
+| `-V`, `--version` | Show version |
 
 ## Interactive UI
 
@@ -137,8 +144,9 @@ Nested menu screens keep `Back` as the final option so you can return to the pre
 ## Development
 
 ```sh
-bash -n devloop install.sh
+bash -n devloop install.sh release.sh skill_helpers.sh
 ./devloop --help
+./devloop --version
 tmp="$(mktemp -d)"
 DEVLOOP_BIN_DIR="$tmp/bin" HOME="$tmp/home" ./install.sh
 PATH="$tmp/bin:$PATH" HOME="$tmp/home" devloop doctor
@@ -146,6 +154,25 @@ PATH="$tmp/bin:$PATH" HOME="$tmp/home" devloop doctor
 
 The supported runtime is the root [`devloop`](devloop) Bash script.
 
+## Versioning and Release
+
+`devloop` uses [Semantic Versioning](https://semver.org/) and stores the current version in [`VERSION`](VERSION). `0.x` releases are initial public API releases, so breaking changes can happen between minor versions.
+
+Release notes in [`CHANGELOG.md`](CHANGELOG.md) are generated from Conventional Commit history with [`git-cliff`](https://git-cliff.org/). Install it before cutting a real release:
+
+```sh
+brew install git-cliff
+```
+
+Cut a release from a clean tree:
+
+```sh
+./release.sh 0.1.0 --dry-run
+./release.sh 0.1.0
+```
+
+That updates `VERSION` and [`CHANGELOG.md`](CHANGELOG.md), runs `bash tests/devloop_test.sh`, commits `chore: release 0.1.0`, and creates an annotated `v0.1.0` tag. Add `--push` to push the release branch and tag. By default, pushed releases must run from `main`.
+
 ## License
 
 MIT. See [LICENSE](LICENSE).
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/cliff.toml b/cliff.toml
new file mode 100644
index 0000000..106aefd
--- /dev/null
+++ b/cliff.toml
@@ -0,0 +1,36 @@
+[changelog]
+header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
+body = """
+{% if version -%}
+## [{{ version | trim_start_matches(pat="v") }}](https://github.com/satyaborg/devloop/releases/tag/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else -%}
+## [Unreleased]
+{% endif -%}
+{% for group, commits in commits | group_by(attribute="group") %}
+### {{ group | upper_first }}
+{% for commit in commits %}
+- {{ commit.message | split(pat="\n") | first | trim }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/satyaborg/devloop/commit/{{ commit.id }}))
+{%- endfor %}
+
+{% endfor %}
+"""
+trim = true
+
+[git]
+conventional_commits = true
+filter_unconventional = false
+split_commits = false
+commit_parsers = [
+  { message = "^chore: release", skip = true },
+  { message = "^Merge pull request", skip = true },
+  { message = "^.+!:", group = "Breaking changes" },
+  { message = "^feat", group = "Added" },
+  { message = "^fix", group = "Fixed" },
+  { message = "^docs", group = "Documentation" },
+  { message = "^test", skip = true },
+  { message = "^chore", skip = true },
+  { message = "^refactor", skip = true },
+  { message = "^.*", skip = true },
+]
+tag_pattern = "v[0-9].*"
+sort_commits = "oldest"
diff --git a/devloop b/devloop
index 67914a6..796945c 100755
--- a/devloop
+++ b/devloop
@@ -17,6 +17,8 @@ while [ -L "$SCRIPT_PATH" ]; do
 done
 ROOT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
 source "$ROOT_DIR/skill_helpers.sh"
+DEVLOOP_VERSION="$(sed -n '1p' "$ROOT_DIR/VERSION" 2>/dev/null || true)"
+if [ -z "$DEVLOOP_VERSION" ]; then DEVLOOP_VERSION="0.0.0-dev"; fi
 
 USE_TUI=false
 if [ -t 1 ]; then USE_TUI=true; fi
@@ -67,6 +69,11 @@ main() {
   if has_arg "--plain" "$@"; then USE_TUI=false; fi
   if has_arg "--tui" "$@"; then USE_TUI=true; fi
 
+  if version_args_only "$@"; then
+    printf 'devloop %s\n' "$DEVLOOP_VERSION"
+    return 0
+  fi
+
   if [ "${1:-}" = "spec" ]; then
     shift
     spec_command "$@"
@@ -127,6 +134,7 @@ devloop_logo() {
 ░█░█░█▀▀░▀▄▀░█░░░█░█░█░█░█▀▀
 ░▀▀░░▀▀▀░░▀░░▀▀▀░▀▀▀░▀▀▀░▀░░
 EOF
+  printf 'v%s\n' "$DEVLOOP_VERSION"
 }
 
 welcome_plain() {
@@ -162,6 +170,7 @@ Options:
   --create-pr, --pr             push accepted branch and open a PR
   --no-shell, --stay            do not open a shell in the run worktree after completion
   --shell, --enter-worktree     open a shell in the run worktree after completion
+  -V, --version                 show version
   -h, --help                    show this screen
 EOF
 }
@@ -192,11 +201,12 @@ welcome_tui() {
   printf '  %-30s %s\n' "--create-pr, --pr" "push accepted branch and open a PR"
   printf '  %-30s %s\n' "--no-shell, --stay" "do not open a shell after completion"
   printf '  %-30s %s\n' "--shell, --enter-worktree" "open a shell after completion"
+  printf '  %-30s %s\n' "-V, --version" "show version"
   printf '  %-30s %s\n' "-h, --help" "show this screen"
 }
 
 usage() {
-  printf '%s\n' "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] <spec.md> [max=5]"
+  printf '%s\n' "usage: devloop [--version] [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] <spec.md> [max=5]"
   printf '%s\n' "agents: Codex or Claude Code"
 }
 
@@ -222,6 +232,19 @@ has_arg() {
   return 1
 }
 
+version_args_only() {
+  local item saw_version=false
+  [ "$#" -gt 0 ] || return 1
+  for item in "$@"; do
+    case "$item" in
+      --version|-V) saw_version=true ;;
+      --plain|--tui) ;;
+      *) return 1 ;;
+    esac
+  done
+  [ "$saw_version" = true ]
+}
+
 trim_string() {
   printf '%s\n' "$1" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//'
 }
diff --git a/release.sh b/release.sh
new file mode 100755
index 0000000..f54c256
--- /dev/null
+++ b/release.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_PATH="${BASH_SOURCE[0]}"
+while [ -L "$SCRIPT_PATH" ]; do
+  SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
+  SCRIPT_TARGET="$(readlink "$SCRIPT_PATH")"
+  case "$SCRIPT_TARGET" in
+    /*) SCRIPT_PATH="$SCRIPT_TARGET" ;;
+    *) SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_TARGET" ;;
+  esac
+done
+
+ROOT="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)"
+
+release_usage() {
+  cat <<'EOF'
+usage: ./release.sh <version> [--dry-run] [--push]
+
+Creates a release commit and annotated tag from a SemVer version.
+
+Examples:
+  ./release.sh 0.1.0 --dry-run
+  ./release.sh 0.1.0
+  ./release.sh 0.1.0 --push
+EOF
+}
+
+release_version_valid() {
+  local version="$1"
+  printf '%s\n' "$version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
+}
+
+release_tag_for_version() {
+  printf 'v%s\n' "$1"
+}
+
+release_require_command() {
+  local command="$1"
+  if command -v "$command" >/dev/null 2>&1; then return 0; fi
+  printf 'missing required command: %s\n' "$command" >&2
+  return 1
+}
+
+release_assert_clean_tree() {
+  if [ -z "$(git -C "$ROOT" status --porcelain)" ]; then return 0; fi
+  printf '%s\n' "working tree must be clean before release" >&2
+  return 1
+}
+
+release_assert_tag_available() {
+  local tag="$1"
+  if git -C "$ROOT" rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
+    printf 'tag already exists: %s\n' "$tag" >&2
+    return 1
+  fi
+}
+
+release_current_branch() {
+  git -C "$ROOT" branch --show-current
+}
+
+release_assert_push_branch() {
+  local branch
+  branch="$(release_current_branch)"
+  if [ -z "$branch" ]; then
+    printf '%s\n' "refusing to push release from detached HEAD" >&2
+    return 1
+  fi
+  if [ "$branch" = "main" ] || [ "${DEVLOOP_RELEASE_ALLOW_BRANCH:-0}" = "1" ]; then return 0; fi
+  printf 'refusing to push release from branch: %s\n' "$branch" >&2
+  printf '%s\n' "checkout main, or set DEVLOOP_RELEASE_ALLOW_BRANCH=1" >&2
+  return 1
+}
+
+release_main() {
+  local version=""
+  local dry_run=false
+  local push=false
+  local tag branch
+
+  while [ "$#" -gt 0 ]; do
+    case "$1" in
+      --dry-run) dry_run=true ;;
+      --push) push=true ;;
+      -h|--help) release_usage; return 0 ;;
+      --*)
+        printf 'unknown option: %s\n' "$1" >&2
+        release_usage >&2
+        return 2
+        ;;
+      *)
+        if [ -n "$version" ]; then
+          release_usage >&2
+          return 2
+        fi
+        version="$1"
+        ;;
+    esac
+    shift
+  done
+
+  if [ -z "$version" ]; then
+    release_usage >&2
+    return 2
+  fi
+  if ! release_version_valid "$version"; then
+    printf 'invalid SemVer version: %s\n' "$version" >&2
+    return 2
+  fi
+
+  tag="$(release_tag_for_version "$version")"
+  release_require_command git
+  if [ "$dry_run" = true ]; then
+    release_assert_tag_available "$tag"
+    printf 'release: %s\n' "$tag"
+    if [ -n "$(git -C "$ROOT" status --porcelain)" ]; then
+      printf '%s\n' "note: actual release requires a clean working tree"
+    fi
+    printf '%s\n' "would run bash tests/devloop_test.sh"
+    printf '%s\n' "would update VERSION and CHANGELOG.md"
+    printf 'would commit: chore: release %s\n' "$version"
+    printf 'would tag: %s\n' "$tag"
+    if [ "$push" = true ]; then printf '%s\n' "would push branch and tag"; fi
+    return 0
+  fi
+
+  release_require_command git-cliff
+  release_assert_tag_available "$tag"
+  release_assert_clean_tree
+  if [ "$push" = true ]; then release_assert_push_branch; fi
+
+  bash "$ROOT/tests/devloop_test.sh"
+  printf '%s\n' "$version" > "$ROOT/VERSION"
+  git-cliff --config "$ROOT/cliff.toml" --workdir "$ROOT" --tag "$tag" --output "$ROOT/CHANGELOG.md"
+  git -C "$ROOT" add VERSION CHANGELOG.md
+  git -C "$ROOT" commit -m "chore: release $version"
+  git -C "$ROOT" tag -a "$tag" -m "devloop $version"
+
+  if [ "$push" = true ]; then
+    branch="$(release_current_branch)"
+    git -C "$ROOT" push origin "$branch"
+    git -C "$ROOT" push origin "$tag"
+  fi
+
+  printf 'released %s\n' "$tag"
+}
+
+if [ "${DEVLOOP_RELEASE_LIB:-}" != "1" ]; then
+  release_main "$@"
+fi
diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh
index 4da0257..92d64b8 100755
--- a/tests/devloop_test.sh
+++ b/tests/devloop_test.sh
@@ -26,7 +26,7 @@ equals() {
   [[ "$actual" == "$expected" ]] || fail "$label expected [$expected], got [$actual]"
 }
 
-bash -n "$ROOT/devloop" "$ROOT/install.sh" "$ROOT/skill_helpers.sh"
+bash -n "$ROOT/devloop" "$ROOT/install.sh" "$ROOT/skill_helpers.sh" "$ROOT/release.sh"
 ok "bash syntax"
 
 DEVLOOP_LIB=1
@@ -35,6 +35,11 @@ unset DEVLOOP_LIB
 equals "${CODEX_MODEL_ARGS[*]}" "-m gpt-5.5" "codex model args"
 equals "${CLAUDE_MODEL_ARGS[*]}" "--model claude-opus-4-8" "claude model args"
 
+version="$(sed -n '1p' "$ROOT/VERSION")"
+equals "$("$ROOT/devloop" --version)" "devloop $version" "version output"
+equals "$("$ROOT/devloop" -V)" "devloop $version" "short version output"
+equals "$("$ROOT/devloop" --plain --version)" "devloop $version" "version after global flag"
+
 help="$("$ROOT/devloop" --help)"
 contains "$help" "Common commands:" "help"
 contains "$help" "devloop doctor" "help"
@@ -42,6 +47,8 @@ contains "$help" "devloop reports" "help"
 contains "$help" "--create-pr" "help"
 contains "$help" "--no-shell" "help"
 contains "$help" "--enter-worktree" "help"
+contains "$help" "--version" "help"
+contains "$help" "v$version" "help"
 ok "help output"
 
 skill_path="$("$ROOT/devloop" spec --skill-path)"
@@ -262,6 +269,7 @@ session_output=$'unrelated 11111111-1111-4111-8111-111111111111\nTo continue thi
 equals "$(extract_session_id "$session_output")" "22222222-2222-4222-8222-222222222222" "extract_session_id uses session marker"
 
 contains "$(devloop_logo)" "░█▀▄░█▀▀" "devloop logo"
+contains "$(devloop_logo)" "v$version" "devloop logo version"
 equals "$(ui_color_code accent)" "38;5;141" "accent color"
 equals "$(ui_color_code rec)" "38;5;135" "run color"
 equals "$(ui_color_code ok)" "38;5;141" "ok color"
@@ -288,6 +296,24 @@ PATH="$old_path"
 contains "$uuid_one" "00000000-0000-4000-8000-" "new_uuid fallback format"
 ok "pure helpers"
 
+(
+  DEVLOOP_RELEASE_LIB=1
+  source "$ROOT/release.sh"
+  release_version_valid "0.1.0" || fail "release version rejected valid patch"
+  release_version_valid "1.2.3-alpha.1+build.7" || fail "release version rejected valid prerelease"
+  if release_version_valid "01.2.3"; then fail "release version accepted leading zero"; fi
+  if release_version_valid "1.2"; then fail "release version accepted missing patch"; fi
+  if release_version_valid "1.2.3-alpha.01"; then fail "release version accepted leading zero prerelease"; fi
+  equals "$(release_tag_for_version "1.2.3")" "v1.2.3" "release tag"
+  release_require_command() {
+    if [ "$1" = "git-cliff" ]; then return 1; fi
+    return 0
+  }
+  dry_run_output="$(release_main "9.9.9" --dry-run)" || fail "release dry-run required git-cliff"
+  contains "$dry_run_output" "would tag: v9.9.9" "release dry-run"
+)
+ok "release helpers"
+
 bin_dir="$work/bin"
 install_home="$work/install-home"
 DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" "$ROOT/install.sh" >/tmp/devloop-install-test.out
