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/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 cce8b3c..59518fc 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/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 f670513..371e4c7 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -85,6 +85,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 "" } 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.