From 4dc22c7500f2c23700e948b558513b4f30ff840f Mon Sep 17 00:00:00 2001
From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Date: Sat, 16 May 2026 16:12:53 -0400
Subject: [PATCH 1/3] ci: publish Sprout Agent Bundle (sprout-acp +
 sprout-agent + sprout-dev-mcp) to GitHub Releases
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds a Linux release bundle for external services that need to embed a
Sprout agent end-to-end (e.g. sprout-backend-blox).

What's in the bundle:
- sprout-acp      — ACP harness that bridges Sprout events to an 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)

Workflow `.github/workflows/sprout-agent-bundle.yml`:
- Targets x86_64-unknown-linux-musl and aarch64-unknown-linux-musl
- Uses the same `cross@0.2.5` toolchain already pinned in ci.yml
- Triggers:
    push to main                      → updates rolling
                                        `sprout-agent-bundle-latest` release
    tag `sprout-agent-bundle-v*`      → versioned release
    workflow_dispatch                  → manual build (optional publish)
- Uses `gh release` (matches release.yml style), not a new third-party
  action
- Uploads asset + .sha256 sidecar per target

Script `scripts/build-agent-bundle.sh` is the single source of truth for
packaging (CI calls it directly). It:
- Builds with `cross` when cross-compiling, plain `cargo` on host
- Strips binaries (best-effort)
- Emits `bundle.json` with {name, version, git_sha, target, binaries:
  [{name, sha256, size}]} for downstream consumers
- Produces a deterministic-ish tarball (sorted, uid=0/gid=0) with a
  README and bundle.json
- Supports SKIP_BUILD=1 for re-packaging without rebuilding (used in
  local verification and useful for fast iteration)

Replaces `scripts/build-agent-release.sh`, which only packaged
sprout-agent + sprout-dev-mcp.

Not in this PR (intentional, per discussion):
- sprout-backend-blox is not yet switched over to consume the bundle
- macOS/Windows bundles
- sprout-mcp-server, git-credential-nostr, git-sign-nostr as separate
  binaries (the dev-mcp multicall covers them)

Verified locally on aarch64-apple-darwin with SKIP_BUILD=1: tarball
contains the 3 binaries + README.md + bundle.json, sha256 sidecar
matches, and bundle.json parses cleanly under jq.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
---
 .github/workflows/sprout-agent-bundle.yml | 198 +++++++++++++++++++++
 .gitignore                                |   1 +
 scripts/build-agent-bundle.sh             | 207 ++++++++++++++++++++++
 scripts/build-agent-release.sh            |  97 ----------
 4 files changed, 406 insertions(+), 97 deletions(-)
 create mode 100644 .github/workflows/sprout-agent-bundle.yml
 create mode 100755 scripts/build-agent-bundle.sh
 delete mode 100755 scripts/build-agent-release.sh

diff --git a/.github/workflows/sprout-agent-bundle.yml b/.github/workflows/sprout-agent-bundle.yml
new file mode 100644
index 0000000000..9a6c43846b
--- /dev/null
+++ b/.github/workflows/sprout-agent-bundle.yml
@@ -0,0 +1,198 @@
+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
+        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 binaries
+        env:
+          TARGET: ${{ matrix.target }}
+        run: |
+          cross build --release --target "$TARGET" \
+            -p sprout-acp \
+            -p sprout-agent \
+            -p sprout-dev-mcp
+
+      - name: Package bundle
+        id: pkg
+        env:
+          TARGET: ${{ matrix.target }}
+          VERSION: ${{ steps.ver.outputs.version }}
+          GIT_SHA: ${{ github.sha }}
+        run: |
+          set -euo pipefail
+          ./scripts/build-agent-bundle.sh "$VERSION" "$TARGET"
+          # Capture the produced archive path for the upload step.
+          ARCHIVE="dist/sprout-agent-bundle-${VERSION}-${TARGET}.tar.gz"
+          test -f "$ARCHIVE"
+          echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
+          echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
+
+      - name: Upload workflow artifact
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+        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
+        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 }}
+        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\` (\`${GITHUB_SHA}\`)."
+
+          # 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 \
+              --title "$TITLE" \
+              --notes "$NOTES"
+          else
+            gh release edit "$TAG" --prerelease --title "$TITLE" --notes "$NOTES"
+          fi
+          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
+        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 18c8f7d5f2..b5ab3b4f95 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 0000000000..d7096eb0d6
--- /dev/null
+++ b/scripts/build-agent-bundle.sh
@@ -0,0 +1,207 @@
+#!/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
+#
+# Output:
+#   dist/sprout-agent-bundle-<version>-<target>.tar.gz
+#   dist/sprout-agent-bundle-<version>-<target>.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" <<JSON
+{
+  "name": "sprout-agent-bundle",
+  "version": "${VERSION}",
+  "git_sha": "${GIT_SHA}",
+  "target": "${TARGET}",
+  "binaries": [${ENTRIES_JSON}]
+}
+JSON
+
+# README — human-readable.
+cat > "${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_NAME="sprout-agent-bundle-${VERSION}-${TARGET}.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 `<sha>  <filename>` 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 ab1547bd53..0000000000
--- 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<version>-<target>.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}"

From 866739b83014d8ad4bfd61ded5214b0394f357e0 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Date: Sat, 16 May 2026 16:42:33 -0400
Subject: [PATCH 2/3] =?UTF-8?q?ci(sprout-agent-bundle):=20address=20review?=
 =?UTF-8?q?=20=E2=80=94=20force-move=20rolling=20tag,=20stable=20rolling?=
 =?UTF-8?q?=20asset=20names,=20single-source=20build?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Addresses Max's review on #603.

Rolling release semantics:
- Force-move the `sprout-agent-bundle-latest` git tag to the current
  `main` SHA before touching the release. `gh release edit` updates
  release metadata but does not move the underlying tag, so without
  this the tag would stick to whatever SHA first created the release.
  Uses `gh api PATCH .../git/refs/tags/...` (with force=true) and
  falls back to `POST .../git/refs` for the initial-create case.
- Also pass `--target ${SHA}` to `gh release create/edit` so the
  release's "target commitish" tracks the build SHA.

Stale-asset cleanup on the rolling release:
- Rolling builds now use stable, version-less asset filenames
  (`sprout-agent-bundle-<target>.tar.gz`). `--clobber` overwrites them
  in place on every push, so the release no longer accumulates
  SHA-named tarballs.
- Tagged releases keep the versioned filename
  (`sprout-agent-bundle-<version>-<target>.tar.gz`) for traceability.
- The git SHA + version always live inside `bundle.json` regardless of
  the asset filename, so consumers/debugging never lose provenance.
- `scripts/build-agent-bundle.sh` gains an `ARCHIVE_BASENAME` env var
  override (documented in the header) so the workflow can pick the
  right filename per channel; default unchanged.

Single source of truth for the build:
- Removed the workflow's explicit `cross build` step. The script
  already auto-selects `cross` when target != host, so the previous
  setup was building everything twice.

Verified locally with `SKIP_BUILD=1`:
- Rolling-style basename → `sprout-agent-bundle-<target>.tar.gz`
  with sha sidecar `OK` under `sha256sum -c`.
- Tagged-style basename → `sprout-agent-bundle-<version>-<target>.tar.gz`.
- Both tarballs contain the 3 binaries + README + bundle.json with
  matching shas/sizes.
- actionlint + shellcheck clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
---
 .github/workflows/sprout-agent-bundle.yml | 55 ++++++++++++++++-------
 scripts/build-agent-bundle.sh             | 15 +++++--
 2 files changed, 51 insertions(+), 19 deletions(-)

diff --git a/.github/workflows/sprout-agent-bundle.yml b/.github/workflows/sprout-agent-bundle.yml
index 9a6c43846b..f78114175a 100644
--- a/.github/workflows/sprout-agent-bundle.yml
+++ b/.github/workflows/sprout-agent-bundle.yml
@@ -82,29 +82,30 @@ jobs:
           } >> "$GITHUB_OUTPUT"
           echo "Resolved version=$VERSION channel=$CHANNEL"
 
-      - name: Build binaries
-        env:
-          TARGET: ${{ matrix.target }}
-        run: |
-          cross build --release --target "$TARGET" \
-            -p sprout-acp \
-            -p sprout-agent \
-            -p sprout-dev-mcp
-
-      - name: Package bundle
+      - 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
-          ./scripts/build-agent-bundle.sh "$VERSION" "$TARGET"
-          # Capture the produced archive path for the upload step.
-          ARCHIVE="dist/sprout-agent-bundle-${VERSION}-${TARGET}.tar.gz"
+          # 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=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
+          echo "archive_name=${ARCHIVE_BASENAME}.tar.gz" >> "$GITHUB_OUTPUT"
 
       - name: Upload workflow artifact
         uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -142,21 +143,43 @@ jobs:
       - 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\` (\`${GITHUB_SHA}\`)."
+          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 --title "$TITLE" --notes "$NOTES"
+            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:
diff --git a/scripts/build-agent-bundle.sh b/scripts/build-agent-bundle.sh
index d7096eb0d6..45f7e20bbd 100755
--- a/scripts/build-agent-bundle.sh
+++ b/scripts/build-agent-bundle.sh
@@ -12,10 +12,18 @@
 # 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/[<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-
+#                     <target>`). Defaults to
+#                     `sprout-agent-bundle-<version>-<target>`.
+#   DIST_DIR          output directory (default: dist)
 #
 # Output:
-#   dist/sprout-agent-bundle-<version>-<target>.tar.gz
-#   dist/sprout-agent-bundle-<version>-<target>.tar.gz.sha256
+#   ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz
+#   ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz.sha256
 #
 # The tarball contains:
 #   sprout-acp
@@ -182,7 +190,8 @@ symlink these names next to `sprout-dev-mcp` on the PATH.
 EOF
 
 # Tar.
-ARCHIVE_NAME="sprout-agent-bundle-${VERSION}-${TARGET}.tar.gz"
+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.

From 59a28d059322355c2f23ff18b782943619a5f90c Mon Sep 17 00:00:00 2001
From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Date: Sat, 16 May 2026 16:54:56 -0400
Subject: [PATCH 3/3] ci(sprout-agent-bundle): pin action comments to exact
 tags (zizmor hygiene)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Per the zizmor PR review thread on #603: the SHA-pinned action comments
should match the exact upstream tag, not a broader floating tag, so the
scanner doesn't flag a metadata mismatch.

- taiki-e/install-action: `# v2` → `# v2.75.18` (the SHA's exact tag)
- actions/upload-artifact: `# v4` → `# v4.6.2`
- actions/download-artifact: `# v4` → `# v4.3.0`

Pinned SHAs are unchanged — no behavior change, just correct metadata.
`actions/checkout` and `cashapp/activate-hermit` already match (their
SHAs are the literal `v6` / `v1` commits).

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
---
 .github/workflows/sprout-agent-bundle.yml | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/sprout-agent-bundle.yml b/.github/workflows/sprout-agent-bundle.yml
index f78114175a..4371241131 100644
--- a/.github/workflows/sprout-agent-bundle.yml
+++ b/.github/workflows/sprout-agent-bundle.yml
@@ -50,7 +50,7 @@ jobs:
       - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
 
       - name: Install cross
-        uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2
+        uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
         with:
           tool: cross@0.2.5
 
@@ -108,7 +108,7 @@ jobs:
           echo "archive_name=${ARCHIVE_BASENAME}.tar.gz" >> "$GITHUB_OUTPUT"
 
       - name: Upload workflow artifact
-        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
         with:
           name: sprout-agent-bundle-${{ matrix.target }}
           path: |
@@ -131,7 +131,7 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
 
       - name: Download all bundle artifacts
-        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
         with:
           path: dist
           pattern: sprout-agent-bundle-*
@@ -194,7 +194,7 @@ jobs:
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
 
       - name: Download all bundle artifacts
-        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
         with:
           path: dist
           pattern: sprout-agent-bundle-*
