diff --git a/.github/workflows/sprout-agent-bundle.yml b/.github/workflows/sprout-agent-bundle.yml new file mode 100644 index 000000000..437124113 --- /dev/null +++ b/.github/workflows/sprout-agent-bundle.yml @@ -0,0 +1,221 @@ +name: Sprout Agent Bundle + +# Builds and publishes the Sprout Agent Bundle — a Linux tarball containing +# the three binaries an external service (e.g. sprout-backend-blox) needs to +# run a Sprout agent end-to-end: +# +# sprout-acp ACP harness that bridges Sprout events to the LLM agent +# sprout-agent ACP-compliant agent (spawns MCP, calls LLMs) +# sprout-dev-mcp Developer MCP server (multicall: rg, tree, sprout, +# git-credential-nostr, git-sign-nostr) +# +# Targets: x86_64-unknown-linux-musl and aarch64-unknown-linux-musl (static +# musl so the tarball runs on any modern Linux without libc surprises). +# +# Triggers: +# - push to main → updates rolling `sprout-agent-bundle-latest` release +# - tag `sprout-agent-bundle-v*` → versioned release +# - workflow_dispatch → manual canary build (no release publish) + +on: + push: + branches: [main] + tags: ["sprout-agent-bundle-v*"] + workflow_dispatch: + inputs: + publish: + description: "Publish to the rolling release (otherwise artifacts only)" + type: boolean + default: false + +permissions: + contents: read + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + + - name: Install cross + uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18 + with: + tool: cross@0.2.5 + + - name: Resolve version + id: ver + run: | + set -euo pipefail + WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \ + | jq -r '.workspace_default_members[0] as $m | .packages[] | select(.id==$m) | .version') + if [[ -z "$WORKSPACE_VERSION" || "$WORKSPACE_VERSION" == "null" ]]; then + # Fallback: read sprout-acp's version directly. + WORKSPACE_VERSION=$(cargo metadata --no-deps --format-version=1 \ + | jq -r '.packages[] | select(.name=="sprout-acp") | .version') + fi + + REF="${GITHUB_REF#refs/tags/}" + if [[ "$GITHUB_REF" == refs/tags/sprout-agent-bundle-v* ]]; then + VERSION="${REF#sprout-agent-bundle-v}" + CHANNEL="tag" + else + SHORT_SHA="${GITHUB_SHA::7}" + VERSION="${WORKSPACE_VERSION}+git.${SHORT_SHA}" + CHANNEL="rolling" + fi + { + echo "workspace_version=$WORKSPACE_VERSION" + echo "version=$VERSION" + echo "channel=$CHANNEL" + } >> "$GITHUB_OUTPUT" + echo "Resolved version=$VERSION channel=$CHANNEL" + + - name: Build & package bundle + id: pkg + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ steps.ver.outputs.version }} + CHANNEL: ${{ steps.ver.outputs.channel }} + GIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + # Rolling releases use stable, version-less filenames so the + # asset overwrites cleanly on every push to main. Tagged + # releases keep the version in the filename for traceability. + # The git SHA + version always live inside bundle.json. + if [[ "$CHANNEL" == "tag" ]]; then + ARCHIVE_BASENAME="sprout-agent-bundle-${VERSION}-${TARGET}" + else + ARCHIVE_BASENAME="sprout-agent-bundle-${TARGET}" + fi + ARCHIVE_BASENAME="$ARCHIVE_BASENAME" \ + ./scripts/build-agent-bundle.sh "$VERSION" "$TARGET" + ARCHIVE="dist/${ARCHIVE_BASENAME}.tar.gz" + test -f "$ARCHIVE" + echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" + echo "archive_name=${ARCHIVE_BASENAME}.tar.gz" >> "$GITHUB_OUTPUT" + + - name: Upload workflow artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sprout-agent-bundle-${{ matrix.target }} + path: | + ${{ steps.pkg.outputs.archive }} + ${{ steps.pkg.outputs.archive }}.sha256 + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish rolling release + needs: build + if: | + github.event_name == 'push' && github.ref == 'refs/heads/main' + || (github.event_name == 'workflow_dispatch' && inputs.publish) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Download all bundle artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: dist + pattern: sprout-agent-bundle-* + merge-multiple: true + + - name: List assets + run: ls -lh dist/ + + - name: Update rolling release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + TAG="sprout-agent-bundle-latest" + TITLE="Sprout Agent Bundle (rolling)" + NOTES="Rolling Linux build of the Sprout Agent Bundle (sprout-acp + sprout-agent + sprout-dev-mcp), tracking \`main\` (\`${SHA}\`)." + + # Force-move the underlying git tag to the current SHA before + # we touch the release. `gh release edit` updates release + # metadata but does *not* move the tag, so without this the + # tag would stick to whatever SHA first created the release. + if gh api "repos/${REPO}/git/refs/tags/${TAG}" >/dev/null 2>&1; then + gh api -X PATCH "repos/${REPO}/git/refs/tags/${TAG}" \ + -f sha="${SHA}" -F force=true >/dev/null + else + gh api -X POST "repos/${REPO}/git/refs" \ + -f ref="refs/tags/${TAG}" -f sha="${SHA}" >/dev/null + fi + + # Create the release if it doesn't exist; otherwise reuse it. + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" \ + --prerelease \ + --target "${SHA}" \ + --title "$TITLE" \ + --notes "$NOTES" + else + gh release edit "$TAG" \ + --prerelease \ + --target "${SHA}" \ + --title "$TITLE" \ + --notes "$NOTES" + fi + # Asset filenames are stable for rolling builds (no version + # in filename — see the package step), so --clobber overwrites + # them in place. No stale-asset accumulation. + gh release upload "$TAG" dist/* --clobber + + publish-tag: + name: Publish tagged release + needs: build + if: startsWith(github.ref, 'refs/tags/sprout-agent-bundle-v') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Download all bundle artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: dist + pattern: sprout-agent-bundle-* + merge-multiple: true + + - name: Resolve tag version + id: ver + run: | + REF="${GITHUB_REF#refs/tags/}" + VERSION="${REF#sprout-agent-bundle-v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$REF" >> "$GITHUB_OUTPUT" + + - name: Create tagged release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.ver.outputs.tag }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + gh release create "$TAG" \ + --title "Sprout Agent Bundle v${VERSION}" \ + --notes "Sprout Agent Bundle v${VERSION} — Linux builds of sprout-acp + sprout-agent + sprout-dev-mcp." \ + dist/* diff --git a/.gitignore b/.gitignore index 18c8f7d5f..b5ab3b4f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build artifacts /target/ +/dist/ # Environment files (may contain secrets) .env diff --git a/scripts/build-agent-bundle.sh b/scripts/build-agent-bundle.sh new file mode 100755 index 000000000..45f7e20bb --- /dev/null +++ b/scripts/build-agent-bundle.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Build the Sprout Agent Bundle — a tarball containing the three binaries +# needed to run a Sprout agent end-to-end: +# +# sprout-acp ACP harness +# sprout-agent ACP-compliant agent (spawns MCP, calls LLMs) +# sprout-dev-mcp Developer MCP server (multicall: rg/tree/sprout/git-*) +# +# Usage: +# ./scripts/build-agent-bundle.sh [version] [target] +# +# Environment overrides: +# TARGET cross-compile target (defaults to host) +# USE_CROSS=1 use `cross` instead of `cargo` for the build +# SKIP_BUILD=1 skip the cargo/cross build (use prebuilt binaries +# already present in target/[/]release) +# ARCHIVE_BASENAME override the archive basename (sans .tar.gz). Useful +# for rolling releases where the asset filename should +# be stable across builds (e.g. `sprout-agent-bundle- +# `). Defaults to +# `sprout-agent-bundle--`. +# DIST_DIR output directory (default: dist) +# +# Output: +# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz +# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz.sha256 +# +# The tarball contains: +# sprout-acp +# sprout-agent +# sprout-dev-mcp +# README.md +# bundle.json { version, git_sha, target, binaries: [{name, sha256, size}] } + +set -euo pipefail + +VERSION="${1:-${VERSION:-0.0.0-dev}}" +HOST_TARGET="$(rustc -vV | sed -n 's|host: ||p')" +TARGET="${2:-${TARGET:-$HOST_TARGET}}" +DIST_DIR="${DIST_DIR:-dist}" + +# Resolve git SHA (best effort — works in CI checkout and local clones). +if GIT_SHA="$(git rev-parse HEAD 2>/dev/null)"; then + : +else + GIT_SHA="unknown" +fi + +BINARIES=(sprout-acp sprout-agent sprout-dev-mcp) + +echo "==> Building Sprout Agent Bundle v${VERSION} for ${TARGET}" +echo " git_sha=${GIT_SHA}" +echo " binaries=${BINARIES[*]}" + +# Pick build driver. `cross` is required for cross-compilation in CI; +# for host builds we use plain `cargo` so contributors don't need Docker. +if [[ "${USE_CROSS:-0}" == "1" ]] || [[ "$TARGET" != "$HOST_TARGET" ]]; then + if ! command -v cross >/dev/null 2>&1; then + echo "error: cross-compiling to $TARGET requires \`cross\` (install: cargo install cross --version 0.2.5)" >&2 + exit 1 + fi + BUILDER=(cross build --release --target "$TARGET") + BIN_DIR="target/${TARGET}/release" +else + BUILDER=(cargo build --release) + BIN_DIR="target/release" +fi + +PKG_ARGS=() +for bin in "${BINARIES[@]}"; do + PKG_ARGS+=(-p "$bin") +done + +if [[ "${SKIP_BUILD:-0}" == "1" ]]; then + echo " (SKIP_BUILD=1 set — expecting prebuilt binaries in ${BIN_DIR}/)" +else + "${BUILDER[@]}" "${PKG_ARGS[@]}" +fi + +# Verify all binaries exist. +for bin in "${BINARIES[@]}"; do + if [[ ! -f "${BIN_DIR}/${bin}" ]]; then + echo "error: ${BIN_DIR}/${bin} not found after build" >&2 + exit 1 + fi +done + +# Stage into a tempdir. +mkdir -p "${DIST_DIR}" +STAGING="$(mktemp -d)" +trap 'rm -rf "${STAGING}"' EXIT + +# sha256 helper: prefer sha256sum (linux), fall back to shasum -a 256 (macos). +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# Copy + strip binaries, collect manifest entries. +MANIFEST_ENTRIES=() +for bin in "${BINARIES[@]}"; do + cp "${BIN_DIR}/${bin}" "${STAGING}/${bin}" + chmod 0755 "${STAGING}/${bin}" + # Best-effort strip. `cross` images include the cross-target strip; on host + # we use the system strip. Skip silently if unavailable (e.g. cross-arch + # local builds on macOS). + if command -v strip >/dev/null 2>&1; then + strip "${STAGING}/${bin}" 2>/dev/null || true + fi + sha="$(sha256_of "${STAGING}/${bin}")" + size="$(wc -c < "${STAGING}/${bin}" | tr -d ' ')" + MANIFEST_ENTRIES+=("{\"name\":\"${bin}\",\"sha256\":\"${sha}\",\"size\":${size}}") +done + +# bundle.json — machine-readable manifest. +ENTRIES_JSON="$(IFS=,; echo "${MANIFEST_ENTRIES[*]}")" +cat > "${STAGING}/bundle.json" < "${STAGING}/README.md" <<'EOF' +# Sprout Agent Bundle + +Linux build of the three binaries needed to run a Sprout agent end-to-end: + +- `sprout-acp` — ACP harness that bridges Sprout channel events to an + ACP-compliant agent over stdio. +- `sprout-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs). +- `sprout-dev-mcp` — Developer MCP server (shell, str_replace, todo) and + multicall entrypoint for `rg`, `tree`, `sprout`, `git-credential-nostr`, + `git-sign-nostr`. + +See `bundle.json` for binary SHA-256s, sizes, and the source git SHA. + +## Install + +```bash +tar -xzf sprout-agent-bundle-*.tar.gz -C /opt/sprout-agent +export PATH="/opt/sprout-agent:$PATH" +``` + +## Configure + +```bash +# Agent provider +export SPROUT_AGENT_PROVIDER=anthropic # or openai +export ANTHROPIC_API_KEY=sk-... +export ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# Nostr identity (shared by sprout-acp, git auth, signing, and sprout CLI) +export NOSTR_PRIVATE_KEY=nsec1... +export SPROUT_PRIVATE_KEY="$NOSTR_PRIVATE_KEY" +export SPROUT_RELAY_URL=https://your-relay.example.com +``` + +## Git Integration + +When `NOSTR_PRIVATE_KEY` is set, `sprout-dev-mcp` automatically configures +git to use nostr-based credential auth and commit signing for all shell +commands. This is ephemeral (session-scoped via `GIT_CONFIG_*` env vars) — +your persistent git config is never modified. + +The nostr credential helper is additive: it silently declines non-Sprout +remotes so git falls through to your system credential helpers for GitHub, +GitLab, etc. `NOSTR_PRIVATE_KEY` is written to a 0600 keyfile and removed +from the process environment — shell commands cannot read it from env. + +## Multicall Binary + +`sprout-dev-mcp` is a multicall binary. When symlinked/invoked as: + +- `rg` — ripgrep-compatible search +- `tree` — directory tree with line counts +- `sprout` — Sprout relay CLI +- `git-credential-nostr` — NIP-98 git credential helper +- `git-sign-nostr` — NIP-GS git commit/tag signing + +…it dispatches to the corresponding subcommand. The installer is free to +symlink these names next to `sprout-dev-mcp` on the PATH. +EOF + +# Tar. +ARCHIVE_BASENAME="${ARCHIVE_BASENAME:-sprout-agent-bundle-${VERSION}-${TARGET}}" +ARCHIVE_NAME="${ARCHIVE_BASENAME}.tar.gz" +ARCHIVE_PATH="${DIST_DIR}/${ARCHIVE_NAME}" + +# Deterministic-ish tar: sorted entries, no owner/group info. +tar \ + --sort=name \ + --owner=0 --group=0 --numeric-owner \ + -czf "${ARCHIVE_PATH}" \ + -C "${STAGING}" \ + . 2>/dev/null || \ +tar -czf "${ARCHIVE_PATH}" -C "${STAGING}" . # fallback for BSD tar (macOS) + +# Sidecar checksum. +sha256_of "${ARCHIVE_PATH}" > "${ARCHIVE_PATH}.sha256" +# Pretty-print the form ` ` like sha256sum -c expects. +echo "$(cat "${ARCHIVE_PATH}.sha256") ${ARCHIVE_NAME}" > "${ARCHIVE_PATH}.sha256" + +echo "" +echo "==> Built: ${ARCHIVE_PATH}" +ls -lh "${ARCHIVE_PATH}" "${ARCHIVE_PATH}.sha256" +echo "" +echo "==> bundle.json:" +sed 's/^/ /' "${STAGING}/bundle.json" diff --git a/scripts/build-agent-release.sh b/scripts/build-agent-release.sh deleted file mode 100755 index ab1547bd5..000000000 --- a/scripts/build-agent-release.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build a release tarball containing sprout-agent + sprout-dev-mcp. -# Usage: ./scripts/build-agent-release.sh [version] -# TARGET=aarch64-unknown-linux-musl ./scripts/build-agent-release.sh 0.1.0 -# Output: dist/sprout-agent-v-.tar.gz - -VERSION="${1:-0.1.0}" -HOST_TARGET="$(rustc -vV | sed -n 's|host: ||p')" -TARGET="${TARGET:-$HOST_TARGET}" -DIST_DIR="dist" - -echo "Building sprout-agent release v${VERSION} for ${TARGET}..." - -# Build release binaries — use --target only when cross-compiling. -if [[ "$TARGET" == "$HOST_TARGET" ]]; then - cargo build --release -p sprout-agent -p sprout-dev-mcp - BIN_DIR="target/release" -else - cargo build --release --target "$TARGET" -p sprout-agent -p sprout-dev-mcp - BIN_DIR="target/${TARGET}/release" -fi - -# Verify binaries exist -for bin in sprout-agent sprout-dev-mcp; do - if [[ ! -f "${BIN_DIR}/${bin}" ]]; then - echo "error: ${BIN_DIR}/${bin} not found" >&2 - exit 1 - fi -done - -# Package -mkdir -p "${DIST_DIR}" -ARCHIVE_NAME="sprout-agent-v${VERSION}-${TARGET}.tar.gz" -STAGING=$(mktemp -d) -trap 'rm -rf "${STAGING}"' EXIT - -cp "${BIN_DIR}/sprout-agent" "${STAGING}/" -cp "${BIN_DIR}/sprout-dev-mcp" "${STAGING}/" - -cat > "${STAGING}/README.md" << 'EOF' -# Sprout Agent - -Minimal ACP agent + developer MCP toolchain. - -## Contents - -- `sprout-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs) -- `sprout-dev-mcp` — Developer MCP server (shell, str_replace, todo, rg, tree, - sprout CLI, git-credential-nostr, git-sign-nostr) - -## Quick Start - -```bash -# Place both binaries on your PATH -export PATH="/path/to/this/dir:$PATH" - -# Set required env vars -export SPROUT_AGENT_PROVIDER=anthropic # or openai -export ANTHROPIC_API_KEY=sk-... -export ANTHROPIC_MODEL=claude-sonnet-4-20250514 - -# Nostr identity (same key for git auth, signing, and relay CLI) -export NOSTR_PRIVATE_KEY=nsec1... -export SPROUT_PRIVATE_KEY=$NOSTR_PRIVATE_KEY -export SPROUT_RELAY_URL=https://your-relay.example.com -``` - -## Git Integration - -When `NOSTR_PRIVATE_KEY` is set, the dev-mcp automatically configures git to -use nostr-based credential auth and commit signing for all shell commands. -This is ephemeral (session-scoped via `GIT_CONFIG_*` env vars) — your -persistent git config is never modified. - -The nostr credential helper is additive: it silently declines non-Sprout -remotes so git falls through to your system credential helpers for GitHub, -GitLab, etc. `NOSTR_PRIVATE_KEY` is written to a 0600 keyfile and removed -from the process environment — shell commands cannot read it from env. - -Set `SPROUT_PRIVATE_KEY` to the same key for the `sprout` relay CLI. - -## Multicall Binary - -`sprout-dev-mcp` is a multicall binary. When symlinked/invoked as: -- `rg` — ripgrep-compatible search -- `tree` — directory tree with line counts -- `sprout` — Sprout relay CLI -- `git-credential-nostr` — NIP-98 git credential helper -- `git-sign-nostr` — NIP-GS git commit/tag signing -EOF - -tar -czf "${DIST_DIR}/${ARCHIVE_NAME}" -C "${STAGING}" . - -echo "Built: ${DIST_DIR}/${ARCHIVE_NAME}" -ls -lh "${DIST_DIR}/${ARCHIVE_NAME}"