From 9c28cff7b60b0bcd2ba8b866aab0632f5bffe725 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 29 Apr 2026 11:42:36 -0400 Subject: [PATCH 1/5] release: add semver-backed version metadata --- CONFIGURATION.md | 2 + README.md | 1 + bin/baudbot | 17 +++++++++ bin/deploy.sh | 14 ++++++- bin/lib/baudbot-runtime.sh | 16 ++++++-- bin/lib/release-runtime-common.sh | 8 +++- bin/lib/version-common.sh | 32 ++++++++++++++++ bin/update-release.sh | 6 +++ bin/update-release.test.sh | 2 + docs/architecture.md | 9 +++-- docs/operations.md | 2 + docs/releases.md | 61 +++++++++++++++++++++++++++++++ 12 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 bin/lib/version-common.sh create mode 100644 docs/releases.md diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 28baffa..cc02c22 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -174,6 +174,8 @@ Broker mode also emits best-effort context usage telemetry in inbox pull `meta` ### Release Updater / Rollback (CLI env overrides) +Baudbot release versioning is driven by the root `package.json.version`. Runtime and release metadata record both semver and git SHA, while on-disk release snapshots remain SHA-addressed. + These are **command-time overrides** for `baudbot update` / `baudbot rollback` (or the underlying scripts). They are not required in `~/.config/.env`. | Variable | Description | Default | diff --git a/README.md b/README.md index 1106df2..6725de5 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ See [SECURITY.md](SECURITY.md) for full threat model, trust boundaries, and know - [docs/linux-runtime.md](docs/linux-runtime.md) — Linux execution model, tools, and constraints - [docs/operations.md](docs/operations.md) — day-2 operations (start/stop/update/rollback/audit) - [docs/architecture.md](docs/architecture.md) — source/runtime/release architecture +- [docs/releases.md](docs/releases.md) — semver policy and release automation - [CONFIGURATION.md](CONFIGURATION.md) — full env var reference - [SECURITY.md](SECURITY.md) — deep security model and vulnerability reporting - [CONTRIBUTING.md](CONTRIBUTING.md) — contribution workflow diff --git a/bin/baudbot b/bin/baudbot index 1cfa282..74d9c15 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -35,6 +35,18 @@ if [ -f "$RUNTIME_NODE_HELPER" ]; then source "$RUNTIME_NODE_HELPER" fi +JSON_COMMON_HELPER="$BAUDBOT_ROOT/bin/lib/json-common.sh" +if [ -f "$JSON_COMMON_HELPER" ]; then + # shellcheck source=bin/lib/json-common.sh + source "$JSON_COMMON_HELPER" +fi + +VERSION_COMMON_HELPER="$BAUDBOT_ROOT/bin/lib/version-common.sh" +if [ -f "$VERSION_COMMON_HELPER" ]; then + # shellcheck source=bin/lib/version-common.sh + source "$VERSION_COMMON_HELPER" +fi + json_get_string_or_empty() { local file="$1" local key="$2" @@ -63,6 +75,11 @@ else fi version() { + if [ -n "${VERSION_COMMON_HELPER:-}" ] && [ -f "$VERSION_COMMON_HELPER" ]; then + bb_package_version_or_unknown "$BAUDBOT_ROOT" + return 0 + fi + local package_json="$BAUDBOT_ROOT/package.json" local pkg_version="" diff --git a/bin/deploy.sh b/bin/deploy.sh index f511f8f..25a4505 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -28,6 +28,8 @@ source "$SCRIPT_DIR/lib/json-common.sh" source "$SCRIPT_DIR/lib/deploy-common.sh" # shellcheck source=bin/lib/runtime-node.sh source "$SCRIPT_DIR/lib/runtime-node.sh" +# shellcheck source=bin/lib/version-common.sh +source "$SCRIPT_DIR/lib/version-common.sh" bb_enable_strict_mode bb_init_paths @@ -422,25 +424,35 @@ if [ "$DRY_RUN" -eq 0 ]; then GIT_SHA="" GIT_SHA_SHORT="" GIT_BRANCH="" + RELEASE_VERSION="" + RELEASE_TAG="" if (cd "$BAUDBOT_SRC" && git rev-parse HEAD >/dev/null 2>&1); then GIT_SHA=$(cd "$BAUDBOT_SRC" && git rev-parse HEAD 2>/dev/null || echo "unknown") GIT_SHA_SHORT=$(cd "$BAUDBOT_SRC" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_BRANCH=$(cd "$BAUDBOT_SRC" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + RELEASE_VERSION="$(bb_package_version_or_unknown "$BAUDBOT_SRC")" + RELEASE_TAG="$(bb_release_tag_for_version "$RELEASE_VERSION")" elif [ -f "$RELEASE_META_FILE" ]; then GIT_SHA="$(json_get_string_or_empty "$RELEASE_META_FILE" "sha")" GIT_SHA_SHORT="$(json_get_string_or_empty "$RELEASE_META_FILE" "short")" GIT_BRANCH="$(json_get_string_or_empty "$RELEASE_META_FILE" "branch")" + RELEASE_VERSION="$(json_get_string_or_empty "$RELEASE_META_FILE" "version")" + RELEASE_TAG="$(json_get_string_or_empty "$RELEASE_META_FILE" "tag")" fi [ -n "$GIT_SHA" ] || GIT_SHA="unknown" [ -n "$GIT_SHA_SHORT" ] || GIT_SHA_SHORT="unknown" [ -n "$GIT_BRANCH" ] || GIT_BRANCH="unknown" + [ -n "$RELEASE_VERSION" ] || RELEASE_VERSION="unknown" + [ -n "$RELEASE_TAG" ] || RELEASE_TAG="$(bb_release_tag_for_version "$RELEASE_VERSION")" DEPLOY_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Write version file via agent as_agent bash -c "cat > '$VERSION_FILE'" </dev/null" || true)" if [ -n "$version_json" ]; then + version="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "version" 2>/dev/null || true)" + tag="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "tag" 2>/dev/null || true)" short="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "short" 2>/dev/null || true)" sha="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "sha" 2>/dev/null || true)" branch="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "branch" 2>/dev/null || true)" @@ -35,14 +41,14 @@ print_deployed_version() { fi fi - if [ -z "$short" ] && [ -z "$sha" ] && [ -z "$branch" ] && [ -z "$deployed_at" ]; then + if [ -z "$version" ] && [ -z "$short" ] && [ -z "$sha" ] && [ -z "$branch" ] && [ -z "$deployed_at" ]; then local release_target="" local release_sha="" release_target="$(readlink -f /opt/baudbot/current 2>/dev/null || true)" if printf '%s\n' "$release_target" | grep -Eq '/releases/[0-9a-f]{7,40}$'; then release_sha="${release_target##*/}" - echo -e "${BOLD}deployed version:${RESET} ${release_sha:0:7} sha: $release_sha (from /opt/baudbot/current)" + echo -e "${BOLD}deployed version:${RESET} unknown (${release_sha:0:7}) sha: $release_sha (from /opt/baudbot/current)" else echo -e "${BOLD}deployed version:${RESET} unavailable" fi @@ -53,8 +59,10 @@ print_deployed_version() { short="${sha:0:7}" fi - line="${short:-unknown}" - [ -n "$branch" ] && line="$line (branch: $branch)" + line="${version:-unknown}" + [ -n "$short" ] && line="$line ($short)" + [ -n "$tag" ] && line="$line tag: $tag" + [ -n "$branch" ] && line="$line branch: $branch" [ -n "$deployed_at" ] && line="$line deployed: $deployed_at" [ -n "$sha" ] && line="$line sha: $sha" diff --git a/bin/lib/release-runtime-common.sh b/bin/lib/release-runtime-common.sh index cb422d5..c2ce31a 100644 --- a/bin/lib/release-runtime-common.sh +++ b/bin/lib/release-runtime-common.sh @@ -59,8 +59,10 @@ bb_verify_deployed_release_sha() { local version_file="$BAUDBOT_AGENT_HOME/.pi/agent/baudbot-version.json" local deployed_sha + local deployed_version deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)" + deployed_version="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "version" 2>/dev/null || true)" if [ -z "$deployed_sha" ]; then die "deployed version file missing or unreadable: $version_file" @@ -71,6 +73,10 @@ bb_verify_deployed_release_sha() { fi if [ -n "$verified_label" ]; then - log "deployed version verified: $verified_label" + if [ -n "$deployed_version" ]; then + log "deployed version verified: $deployed_version ($verified_label)" + else + log "deployed version verified: $verified_label" + fi fi } diff --git a/bin/lib/version-common.sh b/bin/lib/version-common.sh new file mode 100644 index 0000000..240e515 --- /dev/null +++ b/bin/lib/version-common.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Shared version helpers for Baudbot shell scripts. + +bb_package_json_path() { + local root="${1:?repo root required}" + echo "$root/package.json" +} + +bb_package_lock_json_path() { + local root="${1:?repo root required}" + echo "$root/package-lock.json" +} + +bb_package_version() { + local root="${1:?repo root required}" + local package_json="" + + package_json="$(bb_package_json_path "$root")" + [ -r "$package_json" ] || return 1 + + json_get_string "$package_json" "version" +} + +bb_package_version_or_unknown() { + local root="${1:?repo root required}" + bb_package_version "$root" 2>/dev/null || echo "unknown" +} + +bb_release_tag_for_version() { + local version="${1:?version required}" + echo "v$version" +} diff --git a/bin/update-release.sh b/bin/update-release.sh index 7ed6ed9..8346c17 100755 --- a/bin/update-release.sh +++ b/bin/update-release.sh @@ -54,6 +54,8 @@ source "$SCRIPT_DIR/lib/release-common.sh" source "$SCRIPT_DIR/lib/release-runtime-common.sh" # shellcheck source=bin/lib/json-common.sh source "$SCRIPT_DIR/lib/json-common.sh" +# shellcheck source=bin/lib/version-common.sh +source "$SCRIPT_DIR/lib/version-common.sh" # --------------------------------------------------------------------------- # Resolve the full path to npm. This script runs as root (sudo) where the @@ -239,12 +241,16 @@ write_release_metadata() { local branch="$3" local deployed_by local built_at + local release_version="" deployed_by="${SUDO_USER:-$(whoami)}" built_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + release_version="$(bb_package_version_or_unknown "$release_dir")" cat > "$release_dir/baudbot-release.json" </ # immutable, git-free snapshots +│ ├── releases// # immutable, git-free snapshots with semver metadata │ ├── current -> releases/ │ └── previous -> releases/ @@ -26,10 +26,11 @@ baudbot_agent user 1. Update is initiated from a target ref/repo. 2. Deploy/update scripts build a staged snapshot. 3. Snapshot is published to `/opt/baudbot/releases/`. -4. Runtime files are deployed for `baudbot_agent`. -5. Symlink switch (`current`) is updated atomically on success. +4. Release metadata records both semver (`package.json.version`) and git SHA provenance. +5. Runtime files are deployed for `baudbot_agent`. +6. Symlink switch (`current`) is updated atomically on success. -This allows reproducible releases and fast rollback. +This allows reproducible releases, semver-based operator visibility, and fast rollback. ## Agent topology diff --git a/docs/operations.md b/docs/operations.md index e85e209..8e053b7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -56,6 +56,8 @@ sudo baudbot update sudo baudbot rollback previous ``` +Release versions are driven by `package.json.version`, while production snapshots remain SHA-addressed under `/opt/baudbot/releases/` for immutability and rollback safety. + Provision with a pinned pi version (optional): ```bash diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..e052791 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,61 @@ +# Releases + +Baudbot uses semantic versioning with the root `package.json` as the canonical product version. + +## Canonical version source + +- `package.json.version` is the single source of truth for the Baudbot product version. +- Git tags use the form `vX.Y.Z`. +- Runtime metadata records both the semver version and the exact git SHA used to build the release snapshot. + +## Semver policy + +- **patch**: bug fixes, operational fixes, internal maintenance that changes shipped behavior in a backward-compatible way +- **minor**: new user-facing features, new capabilities, or notable backward-compatible behavior expansion +- **major**: reserved for intentional breaking changes and handled manually + +## Release model + +Baudbot production releases remain git-free immutable snapshots under `/opt/baudbot/releases/`. + +That SHA-based layout is preserved for: +- immutability +- fast rollback +- exact provenance + +Human-facing tooling should prefer semver, while deployment internals continue to rely on SHAs. + +Each release snapshot includes `baudbot-release.json` with: +- `version` +- `tag` +- `sha` +- `short` +- `branch` +- `source_repo` +- `built_at` +- `built_by` + +The deployed runtime mirrors this in `~/.pi/agent/baudbot-version.json`. + +## Automation + +The `release-on-main` workflow: +- inspects merged PRs since the last release tag +- decides `none`, `patch`, or `minor` +- bumps `package.json.version` +- updates `package-lock.json` +- creates a release commit +- creates tag `vX.Y.Z` +- publishes a GitHub Release + +Major version bumps are manual-only. + +## Operational visibility + +User-facing version output should include semver first and SHA second when available, for example: + +```text +baudbot 0.2.0 (1a2b3c4) +``` + +And status output should show the deployed semver plus SHA-backed provenance. From cbc7551ed5ced495819bffbd2275f00f4218b8f9 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 29 Apr 2026 11:44:35 -0400 Subject: [PATCH 2/5] release: automate semver bumps from PR labels --- .github/release-labeler.yml | 10 ++ .github/workflows/release-on-main.yml | 133 ++++---------------------- bin/lib/version-common.test.sh | 66 +++++++++++++ bin/test.sh | 1 + 4 files changed, 98 insertions(+), 112 deletions(-) create mode 100644 .github/release-labeler.yml create mode 100644 bin/lib/version-common.test.sh diff --git a/.github/release-labeler.yml b/.github/release-labeler.yml new file mode 100644 index 0000000..f6d086d --- /dev/null +++ b/.github/release-labeler.yml @@ -0,0 +1,10 @@ +labels: + - name: release:minor + color: "1d76db" + description: "Release should bump minor version" + - name: release:patch + color: "0e8a16" + description: "Release should bump patch version" + - name: release:none + color: "6e7781" + description: "Release should not cut a version" diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index 35142bd..177e5d7 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -46,7 +46,9 @@ jobs: if [ -z "$LAST_TAG" ]; then LAST_TAG="v0.0.0" fi + CURRENT_VERSION=$(node -p "require('./package.json').version") echo "last_tag=$LAST_TAG" >> "$GITHUB_OUTPUT" + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" - name: Gather merged PRs since last tag id: prs @@ -90,122 +92,29 @@ jobs: echo "has_changes=true" >> "$GITHUB_OUTPUT" - - name: Decide release bump with Claude Haiku 4.5 + - name: Decide release bump from PR labels id: decide if: steps.prs.outputs.has_changes == 'true' - env: - ANTHROPIC_API_KEY: ${{ secrets.CI_ANTHROPIC_KEY }} - LAST_TAG: ${{ steps.last_tag.outputs.last_tag }} run: | set -euo pipefail - if [ -z "${ANTHROPIC_API_KEY:-}" ]; then - echo "Missing CI_ANTHROPIC_KEY secret" >&2 - exit 1 - fi - - read -r -d '' PROMPT <<'PROMPT_EOF' || true - You are a release manager. Analyze merged pull requests since the last release and decide semver bump. - Allowed outputs: - - none - - patch - - minor - - Rules: - - Never return major. Major releases are manual-only. - - Use a judicious standard: user-facing features, capability expansion, or notable additive behavior => minor. - - Bug fixes, refactors, infra/internal changes, docs/tests only => patch or none. - - If no meaningful published change, choose none. - - Return ONLY strict JSON: - {"decision":"none|patch|minor","reason":"short reason","highlights":["...","..."]} - PROMPT_EOF - PROMPT=$(echo "$PROMPT" | sed 's/^ //') - - jq -n \ - --arg model "claude-haiku-4-5" \ - --arg system "You are precise and must output strict JSON only." \ - --arg prompt "$PROMPT" \ - --arg last_tag "$LAST_TAG" \ - --slurpfile prs /tmp/pr_context_capped.json \ - '{ - model: $model, - max_tokens: 700, - temperature: 0, - system: $system, - messages: [ - {role: "user", content: ($prompt + "\n\nLast release tag: " + $last_tag + "\n\nPRs:\n" + ($prs[0]|tojson))} - ] - }' > /tmp/anthropic-payload.json - - curl -sS https://api.anthropic.com/v1/messages \ - -H "x-api-key: ${ANTHROPIC_API_KEY}" \ - -H "anthropic-version: 2023-06-01" \ - -H "content-type: application/json" \ - --data @/tmp/anthropic-payload.json > /tmp/anthropic-response.json - - TEXT=$(jq -r '.content[0].text // empty' /tmp/anthropic-response.json) - if [ -z "$TEXT" ]; then - echo "Invalid Anthropic response" >&2 - cat /tmp/anthropic-response.json >&2 - exit 1 - fi - - echo "$TEXT" > /tmp/decision-raw.txt - python3 - <<'PY' - import json - import re - import sys - text = open('/tmp/decision-raw.txt', encoding='utf-8').read().strip() - - def parse_json(candidate: str): - try: - return json.loads(candidate) - except Exception: - return None - - decision = parse_json(text) - if decision is None: - fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE) - if fenced: - decision = parse_json(fenced.group(1).strip()) - - if decision is None: - decoder = json.JSONDecoder() - for index, ch in enumerate(text): - if ch != '{': - continue - try: - decision, _ = decoder.raw_decode(text[index:]) - break - except Exception: - continue - - if decision is None: - print("Failed to parse release decision JSON from model response", file=sys.stderr) - print(text, file=sys.stderr) - sys.exit(1) - - with open('/tmp/decision.json', 'w', encoding='utf-8') as fh: - json.dump(decision, fh) - fh.write('\n') - PY - - DECISION=$(jq -r '.decision' /tmp/decision.json) - REASON=$(jq -r '.reason' /tmp/decision.json) - - if [ "$DECISION" = "major" ]; then - echo "Major bump proposed but blocked by policy" >&2 - exit 1 - fi - - case "$DECISION" in - none|patch|minor) ;; - *) - echo "Unexpected decision: $DECISION" >&2 - exit 1 - ;; - esac + DECISION=$(jq -r ' + if any(.[]; (.labels // []) | index("release:minor")) then "minor" + elif any(.[]; (.labels // []) | index("release:patch")) then "patch" + elif all(.[]; ((.labels // []) | index("release:none"))) then "none" + else "patch" + end + ' /tmp/pr_context.json) + + REASON=$(jq -r --arg decision "$DECISION" ' + if $decision == "minor" then + "At least one merged PR requested a minor release via label." + elif $decision == "patch" then + "No minor label found; defaulting to patch for shipped changes." + else + "All merged PRs were explicitly marked release:none." + end + ' /tmp/pr_context.json) echo "decision=$DECISION" >> "$GITHUB_OUTPUT" echo "reason=$REASON" >> "$GITHUB_OUTPUT" @@ -228,7 +137,7 @@ jobs: BUMP: ${{ steps.decide.outputs.decision }} run: | set -euo pipefail - CURRENT=$(node -p "require('./package.json').version") + CURRENT="${{ steps.last_tag.outputs.current_version }}" NEXT=$(node -e "const v=process.argv[1].split('.').map(Number); if(process.argv[2]==='minor'){v[1]++; v[2]=0;} else {v[2]++;} process.stdout.write(v.join('.'))" -- "${CURRENT}" "${BUMP}") node -e "const fs=require('fs'); const p='package.json'; const j=JSON.parse(fs.readFileSync(p,'utf8')); j.version=process.argv[1]; fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');" -- "${NEXT}" if [ -f package-lock.json ]; then diff --git a/bin/lib/version-common.test.sh b/bin/lib/version-common.test.sh new file mode 100644 index 0000000..c903027 --- /dev/null +++ b/bin/lib/version-common.test.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Tests for bin/lib/version-common.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=bin/lib/json-common.sh +source "$SCRIPT_DIR/json-common.sh" +# shellcheck source=bin/lib/version-common.sh +source "$SCRIPT_DIR/version-common.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-version-common-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +test_reads_package_version() { + ( + set -euo pipefail + local tmp + tmp="$(mktemp -d /tmp/baudbot-version-common.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + printf '{"version":"1.2.3"}\n' > "$tmp/package.json" + [ "$(bb_package_version "$tmp")" = "1.2.3" ] + ) +} + +test_formats_release_tag() { + ( + set -euo pipefail + [ "$(bb_release_tag_for_version "2.3.4")" = "v2.3.4" ] + ) +} + +echo "=== version-common tests ===" +echo "" + +run_test "reads package.json version" test_reads_package_version +run_test "formats release tag" test_formats_release_tag + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/test.sh b/bin/test.sh index 98cd70b..4208847 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -106,6 +106,7 @@ run_shell_tests() { run "doctor lib helpers" bash bin/lib/doctor-common.test.sh run "update release flow" bash bin/update-release.test.sh run "rollback release" bash bin/rollback-release.test.sh + run "version common" bash bin/lib/version-common.test.sh echo "" } From a113401a55dd445bb956102da035fde3a3a2185c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 10 May 2026 13:38:59 -0400 Subject: [PATCH 3/5] release: address semver review feedback --- .github/workflows/release-on-main.yml | 3 --- bin/baudbot | 2 ++ bin/deploy.sh | 8 ++++++-- bin/lib/version-common.sh | 5 +++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index 177e5d7..63c8df4 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -87,9 +87,6 @@ jobs: jq '[.[] | {number, title, body: (.body // "" | .[:200]), labels: [.labels[].name], user: .user.login, merged_at, html_url}]' /tmp/merged_prs.json > /tmp/pr_context.json - # Cap at 50 most recent PRs for the Claude prompt to keep payload reasonable - jq '[ sort_by(.merged_at) | reverse | .[:50] | reverse | .[] ]' /tmp/pr_context.json > /tmp/pr_context_capped.json - echo "has_changes=true" >> "$GITHUB_OUTPUT" - name: Decide release bump from PR labels diff --git a/bin/baudbot b/bin/baudbot index 74d9c15..c55bf88 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -80,6 +80,8 @@ version() { return 0 fi + # Fallback for partially copied/degraded installations where the shared + # version helper is missing but package.json is still present. local package_json="$BAUDBOT_ROOT/package.json" local pkg_version="" diff --git a/bin/deploy.sh b/bin/deploy.sh index 25a4505..7496e88 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -432,7 +432,9 @@ if [ "$DRY_RUN" -eq 0 ]; then GIT_SHA_SHORT=$(cd "$BAUDBOT_SRC" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_BRANCH=$(cd "$BAUDBOT_SRC" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") RELEASE_VERSION="$(bb_package_version_or_unknown "$BAUDBOT_SRC")" - RELEASE_TAG="$(bb_release_tag_for_version "$RELEASE_VERSION")" + if [ "$RELEASE_VERSION" != "unknown" ]; then + RELEASE_TAG="$(bb_release_tag_for_version "$RELEASE_VERSION")" + fi elif [ -f "$RELEASE_META_FILE" ]; then GIT_SHA="$(json_get_string_or_empty "$RELEASE_META_FILE" "sha")" GIT_SHA_SHORT="$(json_get_string_or_empty "$RELEASE_META_FILE" "short")" @@ -445,7 +447,9 @@ if [ "$DRY_RUN" -eq 0 ]; then [ -n "$GIT_SHA_SHORT" ] || GIT_SHA_SHORT="unknown" [ -n "$GIT_BRANCH" ] || GIT_BRANCH="unknown" [ -n "$RELEASE_VERSION" ] || RELEASE_VERSION="unknown" - [ -n "$RELEASE_TAG" ] || RELEASE_TAG="$(bb_release_tag_for_version "$RELEASE_VERSION")" + if [ -z "$RELEASE_TAG" ] && [ "$RELEASE_VERSION" != "unknown" ]; then + RELEASE_TAG="$(bb_release_tag_for_version "$RELEASE_VERSION")" + fi DEPLOY_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Write version file via agent diff --git a/bin/lib/version-common.sh b/bin/lib/version-common.sh index 240e515..8606847 100644 --- a/bin/lib/version-common.sh +++ b/bin/lib/version-common.sh @@ -1,5 +1,6 @@ #!/bin/bash # Shared version helpers for Baudbot shell scripts. +# Prerequisite: callers must source bin/lib/json-common.sh before this file. bb_package_json_path() { local root="${1:?repo root required}" @@ -17,6 +18,10 @@ bb_package_version() { package_json="$(bb_package_json_path "$root")" [ -r "$package_json" ] || return 1 + if ! command -v json_get_string >/dev/null 2>&1; then + echo "json_get_string unavailable; source bin/lib/json-common.sh before version-common.sh" >&2 + return 1 + fi json_get_string "$package_json" "version" } From 0e75b01e3263302b8fe030269602af8a4ba5b948 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 10 May 2026 14:07:16 -0400 Subject: [PATCH 4/5] release: clarify dry-run summary --- .github/workflows/release-on-main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index 63c8df4..dbe455f 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -206,7 +206,7 @@ jobs: gh release create "$TAG" --title "$TAG" --notes-file "$NOTES" - name: Release summary - if: steps.bump.outputs.tag != '' + if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' run: | echo "## Release created" >> "$GITHUB_STEP_SUMMARY" echo "Tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" From a13333aba72a577dabe784139aa0181790d76c5c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 10 May 2026 14:26:26 -0400 Subject: [PATCH 5/5] release: make semver publishing manual --- .github/release-labeler.yml | 10 -- .github/workflows/release-on-main.yml | 209 ++++++++++++++++---------- CONFIGURATION.md | 2 +- README.md | 2 +- docs/operations.md | 2 +- docs/releases.md | 30 ++-- 6 files changed, 148 insertions(+), 107 deletions(-) delete mode 100644 .github/release-labeler.yml diff --git a/.github/release-labeler.yml b/.github/release-labeler.yml deleted file mode 100644 index f6d086d..0000000 --- a/.github/release-labeler.yml +++ /dev/null @@ -1,10 +0,0 @@ -labels: - - name: release:minor - color: "1d76db" - description: "Release should bump minor version" - - name: release:patch - color: "0e8a16" - description: "Release should bump patch version" - - name: release:none - color: "6e7781" - description: "Release should not cut a version" diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index dbe455f..e29a9ba 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -1,9 +1,26 @@ name: Release on main on: - push: - branches: [main] workflow_dispatch: + inputs: + bump: + description: "Semver bump to apply when exact_version is empty" + required: true + default: patch + type: choice + options: + - patch + - minor + - major + exact_version: + description: "Optional exact version (X.Y.Z or vX.Y.Z); overrides bump" + required: false + type: string + dry_run: + description: "Preview without committing, tagging, or publishing" + required: true + default: true + type: boolean permissions: contents: write @@ -11,18 +28,16 @@ permissions: concurrency: group: release-main - cancel-in-progress: true + cancel-in-progress: false jobs: release: runs-on: ubuntu-latest - if: ${{ !contains(github.event.head_commit.message, '[skip release]') }} - env: - RELEASE_DRY_RUN: ${{ vars.RELEASE_DRY_RUN || 'true' }} steps: - - name: Checkout + - name: Checkout main uses: actions/checkout@v4 with: + ref: main fetch-depth: 0 - name: Setup Node @@ -32,8 +47,10 @@ jobs: - name: Resolve dry-run mode id: dryrun + env: + DRY_RUN: ${{ inputs.dry_run }} run: | - case "${RELEASE_DRY_RUN,,}" in + case "${DRY_RUN,,}" in 1|true|yes|on) echo "enabled=true" >> "$GITHUB_OUTPUT" ;; *) echo "enabled=false" >> "$GITHUB_OUTPUT" ;; esac @@ -62,7 +79,7 @@ jobs: REPO="${OWNER_REPO#*/}" if [ "$LAST_TAG" = "v0.0.0" ]; then - # No prior release — look back 30 days instead of all time + # No prior release — look back 30 days instead of all time. START_DATE=$(date -u -d '30 days ago' '+%Y-%m-%dT%H:%M:%SZ') else START_DATE=$(git log -1 --format=%cI "$LAST_TAG") @@ -79,104 +96,127 @@ jobs: COUNT=$(jq 'length' /tmp/merged_prs.json) echo "count=$COUNT" >> "$GITHUB_OUTPUT" - if [ "$COUNT" -eq 0 ]; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - echo '[]' > /tmp/pr_context.json - exit 0 - fi - jq '[.[] | {number, title, body: (.body // "" | .[:200]), labels: [.labels[].name], user: .user.login, merged_at, html_url}]' /tmp/merged_prs.json > /tmp/pr_context.json - echo "has_changes=true" >> "$GITHUB_OUTPUT" - - - name: Decide release bump from PR labels - id: decide - if: steps.prs.outputs.has_changes == 'true' + - name: Determine target version + id: version + env: + CURRENT: ${{ steps.last_tag.outputs.current_version }} + BUMP: ${{ inputs.bump }} + EXACT_VERSION: ${{ inputs.exact_version }} run: | set -euo pipefail + node <<'NODE' >> "$GITHUB_OUTPUT" + const current = process.env.CURRENT; + const bump = process.env.BUMP; + const exactInput = (process.env.EXACT_VERSION || "").trim(); + const semverPattern = /^\d+\.\d+\.\d+$/; - DECISION=$(jq -r ' - if any(.[]; (.labels // []) | index("release:minor")) then "minor" - elif any(.[]; (.labels // []) | index("release:patch")) then "patch" - elif all(.[]; ((.labels // []) | index("release:none"))) then "none" - else "patch" - end - ' /tmp/pr_context.json) - - REASON=$(jq -r --arg decision "$DECISION" ' - if $decision == "minor" then - "At least one merged PR requested a minor release via label." - elif $decision == "patch" then - "No minor label found; defaulting to patch for shipped changes." - else - "All merged PRs were explicitly marked release:none." - end - ' /tmp/pr_context.json) + function parseVersion(value, label) { + const normalized = value.replace(/^v/, ""); + if (!semverPattern.test(normalized)) { + throw new Error(`${label} must be a stable semver like 1.2.3 or v1.2.3`); + } + return normalized.split(".").map(Number); + } - echo "decision=$DECISION" >> "$GITHUB_OUTPUT" - echo "reason=$REASON" >> "$GITHUB_OUTPUT" + function compareVersions(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; + } - - name: No-op summary - if: steps.prs.outputs.has_changes != 'true' || steps.decide.outputs.decision == 'none' - run: | - echo "## Release decision" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ steps.prs.outputs.has_changes }}" != "true" ]; then - echo "No merged PRs since last tag; skipping release." >> "$GITHUB_STEP_SUMMARY" - else - echo "Decision: none" >> "$GITHUB_STEP_SUMMARY" - echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY" - fi + const currentParts = parseVersion(current, "package.json version"); + let nextParts; + let decision; + let reason; + + if (exactInput) { + nextParts = parseVersion(exactInput, "exact_version"); + if (compareVersions(nextParts, currentParts) < 0) { + throw new Error(`exact_version ${nextParts.join(".")} is lower than current ${current}`); + } + decision = "exact"; + reason = `Manual release requested exact version v${nextParts.join(".")}.`; + } else { + nextParts = [...currentParts]; + if (bump === "major") { + nextParts[0] += 1; + nextParts[1] = 0; + nextParts[2] = 0; + } else if (bump === "minor") { + nextParts[1] += 1; + nextParts[2] = 0; + } else if (bump === "patch") { + nextParts[2] += 1; + } else { + throw new Error(`unsupported bump: ${bump}`); + } + decision = bump; + reason = `Manual release requested ${bump} bump.`; + } + + const next = nextParts.join("."); + console.log(`current=${current}`); + console.log(`next=${next}`); + console.log(`tag=v${next}`); + console.log(`decision=${decision}`); + console.log(`reason=${reason}`); + console.log(`changed=${next === current ? "false" : "true"}`); + NODE - - name: Bump version - id: bump - if: steps.decide.outputs.decision == 'patch' || steps.decide.outputs.decision == 'minor' + - name: Update package version + if: steps.version.outputs.changed == 'true' env: - BUMP: ${{ steps.decide.outputs.decision }} + NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail - CURRENT="${{ steps.last_tag.outputs.current_version }}" - NEXT=$(node -e "const v=process.argv[1].split('.').map(Number); if(process.argv[2]==='minor'){v[1]++; v[2]=0;} else {v[2]++;} process.stdout.write(v.join('.'))" -- "${CURRENT}" "${BUMP}") - node -e "const fs=require('fs'); const p='package.json'; const j=JSON.parse(fs.readFileSync(p,'utf8')); j.version=process.argv[1]; fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');" -- "${NEXT}" + node -e "const fs=require('fs'); const p='package.json'; const j=JSON.parse(fs.readFileSync(p,'utf8')); j.version=process.argv[1]; fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');" -- "$NEXT" if [ -f package-lock.json ]; then npm install --package-lock-only --ignore-scripts fi - echo "current=$CURRENT" >> "$GITHUB_OUTPUT" - echo "next=$NEXT" >> "$GITHUB_OUTPUT" - echo "tag=v${NEXT}" >> "$GITHUB_OUTPUT" - name: Check tag does not already exist id: tag_check - if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' + if: steps.version.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' env: - TAG: ${{ steps.bump.outputs.tag }} + TAG: ${{ steps.version.outputs.tag }} run: | if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Tag already exists: $TAG. Skipping to keep idempotent." + echo "Tag already exists: $TAG. Skipping tag creation to keep idempotent." echo "exists=true" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Commit and tag - if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' && steps.tag_check.outputs.exists != 'true' + - name: Commit package version + if: steps.version.outputs.changed == 'true' && steps.dryrun.outputs.enabled != 'true' && steps.tag_check.outputs.exists != 'true' env: - TAG: ${{ steps.bump.outputs.tag }} + TAG: ${{ steps.version.outputs.tag }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add package.json package-lock.json || true git commit -m "release: ${TAG} [skip release]" + + - name: Tag and push + if: steps.version.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' && steps.tag_check.outputs.exists != 'true' + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail git tag "$TAG" git push --atomic origin main "$TAG" - name: Build changelog markdown id: changelog - if: steps.bump.outputs.tag != '' + if: steps.version.outputs.tag != '' env: - TAG: ${{ steps.bump.outputs.tag }} - DECISION: ${{ steps.decide.outputs.decision }} - REASON: ${{ steps.decide.outputs.reason }} + TAG: ${{ steps.version.outputs.tag }} + DECISION: ${{ steps.version.outputs.decision }} + REASON: ${{ steps.version.outputs.reason }} run: | set -euo pipefail { @@ -186,16 +226,20 @@ jobs: echo echo "Reason: ${REASON}" echo - echo "### Merged PRs" - jq -r '.[] | "- #\(.number) \(.title) (@\(.user)) — \(.html_url)"' /tmp/pr_context.json + echo "### Merged PRs since previous tag" + if [ "$(jq 'length' /tmp/pr_context.json)" -eq 0 ]; then + echo "No merged PRs found since the previous release tag." + else + jq -r '.[] | "- #\(.number) \(.title) (@\(.user)) — \(.html_url)"' /tmp/pr_context.json + fi } > /tmp/release-notes.md echo "notes_path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT" - name: Create GitHub Release - if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' + if: steps.version.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' env: GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.bump.outputs.tag }} + TAG: ${{ steps.version.outputs.tag }} NOTES: ${{ steps.changelog.outputs.notes_path }} run: | set -euo pipefail @@ -206,19 +250,18 @@ jobs: gh release create "$TAG" --title "$TAG" --notes-file "$NOTES" - name: Release summary - if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' + if: steps.version.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' run: | echo "## Release created" >> "$GITHUB_STEP_SUMMARY" - echo "Tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" - echo "Decision: ${{ steps.decide.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY" - echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY" + echo "Tag: ${{ steps.version.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" + echo "Decision: ${{ steps.version.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY" + echo "Reason: ${{ steps.version.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY" - name: Dry-run release summary - if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled == 'true' + if: steps.version.outputs.tag != '' && steps.dryrun.outputs.enabled == 'true' run: | echo "## Dry run: no release published" >> "$GITHUB_STEP_SUMMARY" - echo "Decision: ${{ steps.decide.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY" - echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY" - echo "Current version: ${{ steps.bump.outputs.current }}" >> "$GITHUB_STEP_SUMMARY" - echo "Would publish tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" - + echo "Decision: ${{ steps.version.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY" + echo "Reason: ${{ steps.version.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY" + echo "Current version: ${{ steps.version.outputs.current }}" >> "$GITHUB_STEP_SUMMARY" + echo "Would publish tag: ${{ steps.version.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/CONFIGURATION.md b/CONFIGURATION.md index cc02c22..677fae0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -174,7 +174,7 @@ Broker mode also emits best-effort context usage telemetry in inbox pull `meta` ### Release Updater / Rollback (CLI env overrides) -Baudbot release versioning is driven by the root `package.json.version`. Runtime and release metadata record both semver and git SHA, while on-disk release snapshots remain SHA-addressed. +Baudbot release versioning is driven by the root `package.json.version`. Runtime and release metadata record both semver and git SHA, while on-disk release snapshots remain SHA-addressed. Semver tags are published manually via the **Release on main** GitHub Actions workflow. These are **command-time overrides** for `baudbot update` / `baudbot rollback` (or the underlying scripts). They are not required in `~/.config/.env`. diff --git a/README.md b/README.md index 6725de5..8917464 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ See [SECURITY.md](SECURITY.md) for full threat model, trust boundaries, and know - [docs/linux-runtime.md](docs/linux-runtime.md) — Linux execution model, tools, and constraints - [docs/operations.md](docs/operations.md) — day-2 operations (start/stop/update/rollback/audit) - [docs/architecture.md](docs/architecture.md) — source/runtime/release architecture -- [docs/releases.md](docs/releases.md) — semver policy and release automation +- [docs/releases.md](docs/releases.md) — semver policy and manual release workflow - [CONFIGURATION.md](CONFIGURATION.md) — full env var reference - [SECURITY.md](SECURITY.md) — deep security model and vulnerability reporting - [CONTRIBUTING.md](CONTRIBUTING.md) — contribution workflow diff --git a/docs/operations.md b/docs/operations.md index 8e053b7..6bd7384 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -56,7 +56,7 @@ sudo baudbot update sudo baudbot rollback previous ``` -Release versions are driven by `package.json.version`, while production snapshots remain SHA-addressed under `/opt/baudbot/releases/` for immutability and rollback safety. +Release versions are driven by `package.json.version`, while production snapshots remain SHA-addressed under `/opt/baudbot/releases/` for immutability and rollback safety. Normal PR merges do not cut versions automatically; run the **Release on main** GitHub Actions workflow when you want to publish a semver tag/release. Provision with a pinned pi version (optional): diff --git a/docs/releases.md b/docs/releases.md index e052791..d0fae16 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,7 +12,7 @@ Baudbot uses semantic versioning with the root `package.json` as the canonical p - **patch**: bug fixes, operational fixes, internal maintenance that changes shipped behavior in a backward-compatible way - **minor**: new user-facing features, new capabilities, or notable backward-compatible behavior expansion -- **major**: reserved for intentional breaking changes and handled manually +- **major**: intentional breaking changes ## Release model @@ -37,18 +37,26 @@ Each release snapshot includes `baudbot-release.json` with: The deployed runtime mirrors this in `~/.pi/agent/baudbot-version.json`. -## Automation +## Manual release workflow -The `release-on-main` workflow: -- inspects merged PRs since the last release tag -- decides `none`, `patch`, or `minor` -- bumps `package.json.version` -- updates `package-lock.json` -- creates a release commit -- creates tag `vX.Y.Z` -- publishes a GitHub Release +Normal PR merges do **not** automatically publish a new version. To cut a release, run the **Release on main** GitHub Actions workflow manually. -Major version bumps are manual-only. +Inputs: + +- `bump`: `patch`, `minor`, or `major` — used when `exact_version` is empty +- `exact_version`: optional `X.Y.Z` or `vX.Y.Z` override +- `dry_run`: preview mode; when true, no commit, tag, or GitHub Release is created + +When publishing, the workflow: + +1. checks out `main` +2. computes the target version from `bump` or `exact_version` +3. updates `package.json` and `package-lock.json` when needed +4. commits `release: vX.Y.Z [skip release]` when the package version changed +5. tags `vX.Y.Z` +6. publishes a GitHub Release with merged PR notes since the previous tag + +This keeps version timing human-controlled while still making the release mechanics repeatable. ## Operational visibility