diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index 35142bd..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 @@ -46,7 +63,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 @@ -60,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") @@ -77,200 +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 - # 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 with Claude Haiku 4.5 - id: decide - if: steps.prs.outputs.has_changes == 'true' + - name: Determine target version + id: version env: - ANTHROPIC_API_KEY: ${{ secrets.CI_ANTHROPIC_KEY }} - LAST_TAG: ${{ steps.last_tag.outputs.last_tag }} + CURRENT: ${{ steps.last_tag.outputs.current_version }} + BUMP: ${{ inputs.bump }} + EXACT_VERSION: ${{ inputs.exact_version }} 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 - - echo "decision=$DECISION" >> "$GITHUB_OUTPUT" - echo "reason=$REASON" >> "$GITHUB_OUTPUT" - - - 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 - - - name: Bump version - id: bump - if: steps.decide.outputs.decision == 'patch' || steps.decide.outputs.decision == 'minor' + 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+$/; + + 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); + } + + function compareVersions(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; + } + + 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: 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=$(node -p "require('./package.json').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 { @@ -280,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 @@ -300,19 +250,18 @@ jobs: gh release create "$TAG" --title "$TAG" --notes-file "$NOTES" - name: Release summary - if: steps.bump.outputs.tag != '' + 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 28baffa..677fae0 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. 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`. | Variable | Description | Default | diff --git a/README.md b/README.md index 1106df2..8917464 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 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/bin/baudbot b/bin/baudbot index 1cfa282..c55bf88 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,13 @@ else fi version() { + if [ -n "${VERSION_COMMON_HELPER:-}" ] && [ -f "$VERSION_COMMON_HELPER" ]; then + bb_package_version_or_unknown "$BAUDBOT_ROOT" + 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 f511f8f..7496e88 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,39 @@ 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")" + 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")" 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" + 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 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..8606847 --- /dev/null +++ b/bin/lib/version-common.sh @@ -0,0 +1,37 @@ +#!/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}" + 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 + 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" +} + +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 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 "" } 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..6bd7384 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. 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): ```bash diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..d0fae16 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,69 @@ +# 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**: intentional breaking changes + +## 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`. + +## Manual release workflow + +Normal PR merges do **not** automatically publish a new version. To cut a release, run the **Release on main** GitHub Actions workflow manually. + +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 + +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.