Skip to content

Build Agent Shell Runtimes #29

Build Agent Shell Runtimes

Build Agent Shell Runtimes #29

name: Build Agent Shell Runtimes
# Builds the 8 VIMS agent shell runtimes (openclaw, zeroclaw, nanoclaw,
# nemoclaw, mirofish, picoclaw, hermes, openfang) cross-platform and
# publishes each binary as a GitHub Release asset on THIS repo, keyed on
# the upstream commit SHA. VIMS (private) downloads from these releases
# instead of compiling — public-repo Actions minutes are free, so heavy
# macOS / Rust / PyInstaller work happens here at no cost.
#
# Layout: one Release per `<shell>-<sha12>` containing one asset per
# target platform: `<shell>-<target>[.exe]`. Idempotent across re-runs.
#
# Triggers:
# - schedule: daily 06:00 UTC — picks up upstream commits
# - workflow_dispatch: manual run from Actions tab
# - repository_dispatch type=build-runtimes — invoked by VIMS pre-release
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
inputs:
only:
description: 'Comma-separated shell names to build (default: all 8)'
required: false
default: ''
fresh:
description: 'Delete source trees before cloning (true/false)'
required: false
default: 'false'
repository_dispatch:
types: [build-runtimes]
permissions:
contents: write # needed for gh release create/upload
env:
GO_VERSION: '1.25'
NODE_VERSION: '22'
PNPM_VERSION: '10'
BUN_VERSION: '1.3.11'
RUST_VERSION: 'stable'
PYTHON_VERSION: '3.12'
MACOSX_DEPLOYMENT_TARGET: '11.0'
jobs:
# ---------------------------------------------------------------------------
# Resolve every upstream HEAD SHA exactly ONCE per workflow run. Without this
# shared job, each matrix target re-resolved independently and openclaw's
# fast-moving master branch produced different SHAs per target — meaning no
# single per-SHA tag ever held all 5 platform binaries, and the
# consolidate-latest job (which also re-resolved) hunted for tags that no
# matrix job had ever published. Pin once, share with every downstream job.
# ---------------------------------------------------------------------------
resolve-shas:
name: Resolve upstream shell SHAs (shared)
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
openclaw: ${{ steps.r.outputs.openclaw }}
zeroclaw: ${{ steps.r.outputs.zeroclaw }}
nanoclaw: ${{ steps.r.outputs.nanoclaw }}
nemoclaw: ${{ steps.r.outputs.nemoclaw }}
mirofish: ${{ steps.r.outputs.mirofish }}
picoclaw: ${{ steps.r.outputs.picoclaw }}
hermes: ${{ steps.r.outputs.hermes }}
openfang: ${{ steps.r.outputs.openfang }}
scripthash: ${{ steps.h.outputs.h }}
steps:
- uses: actions/checkout@v4
- id: r
shell: bash
run: |
chmod +x scripts/update-runtimes.sh
scripts/update-runtimes.sh --print-shas | tee /tmp/shas.env
while IFS='=' read -r name sha; do
[[ -n "$name" && "$sha" != "unknown" ]] && echo "$name=$sha" >> "$GITHUB_OUTPUT"
done < /tmp/shas.env
- id: h
shell: bash
run: |
echo "h=${{ hashFiles('scripts/update-runtimes.sh', '.github/workflows/build-runtimes.yml') }}" >> "$GITHUB_OUTPUT"
build:
name: Build runtimes (${{ matrix.target }})
needs: resolve-shas
strategy:
fail-fast: false
matrix:
include:
- target: darwin-arm64
os: macos-14
cross: 'false'
- target: darwin-amd64
os: macos-14
cross: 'true'
- target: linux-amd64
os: ubuntu-latest
cross: 'false'
- target: linux-arm64
os: ubuntu-24.04-arm
cross: 'false'
# ubuntu-24.04-arm free runners are 2 vCPU / 8GB. The default
# parallel cargo + `cc -fuse-ld=lld` link step at the tail of
# openfang's Tauri workspace pushes peak RSS over 8GB and OOM-kills
# the runner right when `openfang-cli` finishes. Capping to 1 job
# serializes the heaviest crates and keeps RSS in budget. Costs
# ~10 extra minutes of wall time; we have 240min headroom.
cargo_jobs: '1'
- target: windows-amd64
os: windows-latest
cross: 'false'
runs-on: ${{ matrix.os }}
# 240 min: cold zeroclaw + openfang Rust workspaces alone burn ~75 min on
# 4-core runners; mirofish PyInstaller adds ~15-20 min. Free public CI has
# no per-minute cost so we can afford generous headroom on cold caches.
timeout-minutes: 240
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install pnpm
run: npm install -g pnpm@${{ env.PNPM_VERSION }}
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Add Rust cross target (darwin-amd64)
if: matrix.target == 'darwin-amd64'
run: rustup target add x86_64-apple-darwin
shell: bash
- name: Install Rosetta 2 + x86_64 Python (darwin-amd64 cross)
if: matrix.target == 'darwin-amd64'
shell: bash
run: |
set -euxo pipefail
softwareupdate --install-rosetta --agree-to-license || true
arch -x86_64 uname -m
if [[ ! -x /usr/local/bin/brew ]]; then
arch -x86_64 /bin/bash -c \
'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
fi
arch -x86_64 /usr/local/bin/brew --version
set +e
arch -x86_64 /usr/local/bin/brew install python@3.11
BREW_EXIT=$?
set -e
if [[ $BREW_EXIT -ne 0 ]]; then
echo "::warning::brew install python@3.11 exited $BREW_EXIT (link conflict tolerated)"
fi
PY_PREFIX="$(arch -x86_64 /usr/local/bin/brew --prefix python@3.11 2>/dev/null || true)"
X86_PYTHON_PATH=""
for cand in \
"$PY_PREFIX/bin/python3.11" \
/usr/local/opt/python@3.11/bin/python3.11 \
"$(ls -td /usr/local/Cellar/python@3.11/*/bin/python3.11 2>/dev/null | head -1)"; do
if [[ -x "$cand" ]]; then X86_PYTHON_PATH="$cand"; break; fi
done
if [[ -z "$X86_PYTHON_PATH" ]]; then
echo "::error::could not locate x86_64 python3.11 keg binary"
ls -la /usr/local/Cellar/python@3.11/ || true
exit 1
fi
file "$X86_PYTHON_PATH"
arch -x86_64 "$X86_PYTHON_PATH" -c 'import platform; print("arch:", platform.machine(), "ver:", platform.python_version())'
echo "X86_PYTHON=$X86_PYTHON_PATH" >> "$GITHUB_ENV"
- name: Cache Rust builds (zeroclaw + openfang)
uses: Swatinem/rust-cache@v2
with:
workspaces: |
runtimes/zeroclaw
runtimes/openfang
key: ${{ matrix.target }}
shared-key: runtimes-${{ matrix.target }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv (for mirofish)
if: matrix.target != 'windows-amd64'
run: curl -LsSf https://astral.sh/uv/install.sh | sh
shell: bash
- name: Install uv (Windows)
if: matrix.target == 'windows-amd64'
run: irm https://astral.sh/uv/install.ps1 | iex
shell: pwsh
- name: Install pipx (for hermes)
if: matrix.target != 'windows-amd64'
run: python -m pip install --user pipx
shell: bash
- name: Free disk space (linux)
if: runner.os == 'Linux'
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost /opt/hostedtoolcache/CodeQL \
/usr/local/share/powershell /usr/share/swift \
/usr/local/.ghcup /opt/microsoft 2>/dev/null || true
sudo docker image prune -af 2>/dev/null || true
sudo apt-get clean
df -h /
shell: bash
- name: Free disk space (macOS)
if: runner.os == 'macOS'
run: |
df -h /
sudo rm -rf /Applications/Xcode_14*.app /Applications/Xcode_15.0*.app \
/Applications/Xcode_15.1*.app /Applications/Xcode_15.2*.app \
/Library/Developer/CoreSimulator/Profiles/Runtimes/* 2>/dev/null || true
df -h /
shell: bash
- name: Install system deps (linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential pkg-config libssl-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev librsvg2-dev patchelf \
libayatana-appindicator3-dev
shell: bash
# =======================================================================
# Per-shell SHA-keyed binary cache (lives on this public repo).
# SHAs come from the shared resolve-shas job so every target/job uses
# the same set — no TOCTOU divergence between matrix workers.
# =======================================================================
- name: Cache openclaw
id: cache-openclaw
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/openclaw${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-openclaw-${{ needs.resolve-shas.outputs.openclaw }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache zeroclaw
id: cache-zeroclaw
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/zeroclaw${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-zeroclaw-${{ needs.resolve-shas.outputs.zeroclaw }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache nanoclaw
id: cache-nanoclaw
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/nanoclaw${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-nanoclaw-${{ needs.resolve-shas.outputs.nanoclaw }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache nemoclaw
id: cache-nemoclaw
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/nemoclaw${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-nemoclaw-${{ needs.resolve-shas.outputs.nemoclaw }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache mirofish
id: cache-mirofish
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/mirofish${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-mirofish-${{ needs.resolve-shas.outputs.mirofish }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache picoclaw
id: cache-picoclaw
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/picoclaw${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-picoclaw-${{ needs.resolve-shas.outputs.picoclaw }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache hermes
id: cache-hermes
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/hermes${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-hermes-${{ needs.resolve-shas.outputs.hermes }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Cache openfang
id: cache-openfang
uses: actions/cache@v4
with:
path: dist/${{ matrix.target }}/openfang${{ matrix.target == 'windows-amd64' && '.exe' || '' }}
key: rt-${{ matrix.target }}-openfang-${{ needs.resolve-shas.outputs.openfang }}-${{ needs.resolve-shas.outputs.scripthash }}
- name: Compute skip list from cache hits
id: skip
shell: bash
env:
HIT_OPENCLAW: ${{ steps.cache-openclaw.outputs.cache-hit }}
HIT_ZEROCLAW: ${{ steps.cache-zeroclaw.outputs.cache-hit }}
HIT_NANOCLAW: ${{ steps.cache-nanoclaw.outputs.cache-hit }}
HIT_NEMOCLAW: ${{ steps.cache-nemoclaw.outputs.cache-hit }}
HIT_MIROFISH: ${{ steps.cache-mirofish.outputs.cache-hit }}
HIT_PICOCLAW: ${{ steps.cache-picoclaw.outputs.cache-hit }}
HIT_HERMES: ${{ steps.cache-hermes.outputs.cache-hit }}
HIT_OPENFANG: ${{ steps.cache-openfang.outputs.cache-hit }}
run: |
set -euo pipefail
skipped=()
for pair in \
"openclaw:$HIT_OPENCLAW" \
"zeroclaw:$HIT_ZEROCLAW" \
"nanoclaw:$HIT_NANOCLAW" \
"nemoclaw:$HIT_NEMOCLAW" \
"mirofish:$HIT_MIROFISH" \
"picoclaw:$HIT_PICOCLAW" \
"hermes:$HIT_HERMES" \
"openfang:$HIT_OPENFANG"; do
name="${pair%%:*}"
hit="${pair##*:}"
if [[ "$hit" == "true" ]]; then
skipped+=("$name")
echo "::notice::cache HIT $name@${{ matrix.target }} — skipping rebuild"
fi
done
# bash 4.x trips `set -u` on `${skipped[*]}` when the array is empty.
# Use the `+alt` parameter expansion to default to '' in that case.
IFS=','; joined="${skipped[*]+${skipped[*]}}"; unset IFS
echo "list=$joined" >> "$GITHUB_OUTPUT"
[[ -n "$joined" ]] && echo "Skipping (cached): $joined" || echo "No cached binaries — building all from source."
# update-runtimes.sh writes to cmd/server/runtimes/binaries/<target>/.
# Move into a clean dist/ tree afterwards for upload simplicity.
#
# We `tee` ALL build output to a survivable log file written to the
# workspace, then upload it unconditionally as an artifact (see end of
# job). Free public `ubuntu-24.04-arm` runners are spot-class and can
# die mid-build with zero log traces returned via the standard agent
# channel — the artifact path bypasses that and gives us post-mortem
# visibility on otherwise-silent failures.
- name: Build runtimes
shell: bash
env:
ONLY: ${{ inputs.only || github.event.client_payload.only || '' }}
FRESH: ${{ inputs.fresh || 'false' }}
MATRIX_TARGET: ${{ matrix.target }}
MATRIX_CROSS: ${{ matrix.cross }}
MATRIX_CARGO_JOBS: ${{ matrix.cargo_jobs }}
SKIP_CACHED: ${{ steps.skip.outputs.list }}
# Honored by every cargo invocation (zeroclaw + openfang). Cargo
# rejects an empty CARGO_BUILD_JOBS="" with `Number of parallel jobs
# should be \`default\` or a number`, so when matrix.cargo_jobs is
# unset (everywhere except linux-arm64 OOM mitigation), pass the
# literal string "default" and let cargo pick its own concurrency.
CARGO_BUILD_JOBS: ${{ matrix.cargo_jobs || 'default' }}
run: |
set -uo pipefail
mkdir -p ci-logs
BUILD_LOG="ci-logs/build-${MATRIX_TARGET}.log"
: > "$BUILD_LOG"
{
echo "[$(date -u +%H:%M:%SZ)] target=${MATRIX_TARGET} host=$(uname -sm) free=$(df -h / | tail -1)"
echo "[mem] $(free -h 2>/dev/null | head -2 || vm_stat 2>/dev/null | head -3)"
[[ -n "${MATRIX_CARGO_JOBS:-}" ]] && echo "[cargo] CARGO_BUILD_JOBS=${MATRIX_CARGO_JOBS} (oom mitigation)"
args=()
[[ "${FRESH:-false}" == "true" ]] && args+=(--fresh)
[[ -n "${ONLY:-}" ]] && args+=(--only "$ONLY")
[[ -n "${SKIP_CACHED:-}" ]] && args+=(--skip "$SKIP_CACHED")
if [[ "${MATRIX_CROSS}" == "true" ]]; then
args+=(--target "${MATRIX_TARGET}")
echo "::notice::Cross-compiling for ${MATRIX_TARGET} from $(uname -sm)"
fi
chmod +x scripts/update-runtimes.sh
ec=0
scripts/update-runtimes.sh ${args[@]+"${args[@]}"} || ec=$?
echo "[$(date -u +%H:%M:%SZ)] update-runtimes.sh exit=${ec}"
if [[ "$ec" -ne 0 ]]; then
echo "::warning::update-runtimes.sh exited $ec — continuing to publish whatever shells did build"
fi
} 2>&1 | tee -a "$BUILD_LOG"
# Avoid `[[ -n "$x" ]] && cmd` style here: when the test fails it
# returns 1, pipefail propagates that past tee, and GH's default
# `bash -eo pipefail` invocation kills the whole step even on a
# successful build. The `if/then/fi` form keeps the brace block
# exiting 0 cleanly.
# Promote successfully-built binaries (incl. cache-restored) into dist/<target>/.
mkdir -p "dist/${MATRIX_TARGET}"
src="cmd/server/runtimes/binaries/${MATRIX_TARGET}"
if [[ -d "$src" ]]; then
for f in "$src"/*; do
[[ -e "$f" ]] || continue
[[ -s "$f" ]] || { echo "skip $(basename "$f"): 0 bytes"; continue; }
cp -f "$f" "dist/${MATRIX_TARGET}/"
done
fi
ls -lh "dist/${MATRIX_TARGET}/" || true
# =======================================================================
# Publish each built binary as an asset on a per-shell-per-SHA Release.
# Uses GITHUB_TOKEN (same-repo, no PAT needed).
# =======================================================================
- name: Publish to releases
if: success() || failure() # publish whatever made it
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TARGET: ${{ matrix.target }}
SHA_OPENCLAW: ${{ needs.resolve-shas.outputs.openclaw }}
SHA_ZEROCLAW: ${{ needs.resolve-shas.outputs.zeroclaw }}
SHA_NANOCLAW: ${{ needs.resolve-shas.outputs.nanoclaw }}
SHA_NEMOCLAW: ${{ needs.resolve-shas.outputs.nemoclaw }}
SHA_MIROFISH: ${{ needs.resolve-shas.outputs.mirofish }}
SHA_PICOCLAW: ${{ needs.resolve-shas.outputs.picoclaw }}
SHA_HERMES: ${{ needs.resolve-shas.outputs.hermes }}
SHA_OPENFANG: ${{ needs.resolve-shas.outputs.openfang }}
run: |
set -uo pipefail
ext=""
[[ "$TARGET" == "windows-amd64" ]] && ext=".exe"
bin_dir="dist/$TARGET"
uploaded=0
for pair in \
"openclaw:$SHA_OPENCLAW" \
"zeroclaw:$SHA_ZEROCLAW" \
"nanoclaw:$SHA_NANOCLAW" \
"nemoclaw:$SHA_NEMOCLAW" \
"mirofish:$SHA_MIROFISH" \
"picoclaw:$SHA_PICOCLAW" \
"hermes:$SHA_HERMES" \
"openfang:$SHA_OPENFANG"; do
shell_name="${pair%%:*}"
sha="${pair##*:}"
[[ -z "$sha" || "$sha" == "unknown" ]] && { echo "skip $shell_name: no SHA"; continue; }
bin="$bin_dir/${shell_name}${ext}"
[[ -f "$bin" && -s "$bin" ]] || { echo "skip $shell_name: $bin missing or empty"; continue; }
sha12="${sha:0:12}"
tag="${shell_name}-${sha12}"
asset="${shell_name}-${TARGET}${ext}"
stage="$RUNNER_TEMP/$asset"
cp -f "$bin" "$stage"
if ! gh release view "$tag" >/dev/null 2>&1; then
notes="$RUNNER_TEMP/notes-${tag}.md"
{
printf 'Pre-built `%s` runtime binary.\n\n' "$shell_name"
printf '**Upstream commit:** `%s`\n' "$sha"
printf '**Built by:** [Runtimes CI run %s](https://github.com/%s/actions/runs/%s)\n' "${GITHUB_RUN_ID}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf '**Built at:** %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'Pin to this exact tag for reproducible deployments.\n'
} > "$notes"
gh release create "$tag" --title "${shell_name} @ ${sha12}" \
--notes-file "$notes" --latest=false --prerelease 2>/tmp/gh.err || {
if grep -qiE "already_exists|already exists" /tmp/gh.err; then :; \
else cat /tmp/gh.err >&2; fi
}
fi
if gh release upload "$tag" "$stage" --clobber 2>/tmp/gh.err; then
size=$(wc -c < "$stage" | tr -d ' ')
echo "::notice::published $asset (${size} bytes) → $tag"
uploaded=$((uploaded + 1))
else
cat /tmp/gh.err >&2
echo "::warning::failed to upload $asset"
fi
done
echo "Published $uploaded asset(s) for $TARGET"
# Always upload the build log as an artifact so silent runner deaths
# (esp. on `ubuntu-24.04-arm` spot capacity) leave us a post-mortem trail
# even when the agent's standard log-upload channel produces nothing.
- name: Upload build log
if: always()
uses: actions/upload-artifact@v4
with:
name: build-log-${{ matrix.target }}
path: ci-logs/
if-no-files-found: ignore
retention-days: 14
# ---------------------------------------------------------------------------
# Consolidate the latest binaries from every shell × target into a single
# rolling "latest" release. Per-SHA tags remain as prereleases for pinning;
# this release is the human-friendly entry point for everyone else.
#
# Runs after the matrix even if some targets failed — we publish whichever
# binaries are currently available rather than gating on full matrix success.
# ---------------------------------------------------------------------------
consolidate-latest:
name: Publish rolling 'latest' release
needs: [resolve-shas, build]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Mirror per-SHA assets into a single 'latest' release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SHA_OPENCLAW: ${{ needs.resolve-shas.outputs.openclaw }}
SHA_ZEROCLAW: ${{ needs.resolve-shas.outputs.zeroclaw }}
SHA_NANOCLAW: ${{ needs.resolve-shas.outputs.nanoclaw }}
SHA_NEMOCLAW: ${{ needs.resolve-shas.outputs.nemoclaw }}
SHA_MIROFISH: ${{ needs.resolve-shas.outputs.mirofish }}
SHA_PICOCLAW: ${{ needs.resolve-shas.outputs.picoclaw }}
SHA_HERMES: ${{ needs.resolve-shas.outputs.hermes }}
SHA_OPENFANG: ${{ needs.resolve-shas.outputs.openfang }}
shell: bash
run: |
set -uo pipefail
mkdir -p staging
declare -A SHAS=(
[openclaw]="$SHA_OPENCLAW"
[zeroclaw]="$SHA_ZEROCLAW"
[nanoclaw]="$SHA_NANOCLAW"
[nemoclaw]="$SHA_NEMOCLAW"
[mirofish]="$SHA_MIROFISH"
[picoclaw]="$SHA_PICOCLAW"
[hermes]="$SHA_HERMES"
[openfang]="$SHA_OPENFANG"
)
# Build the manifest as we go so consumers can resolve SHA→tag without
# an extra `git ls-remote` round-trip.
{
echo "{"
echo " \"updated\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
echo " \"shells\": {"
first=1
for shell_name in openclaw zeroclaw nanoclaw nemoclaw mirofish picoclaw hermes openfang; do
sha="${SHAS[$shell_name]}"
[[ -z "$sha" ]] && continue
[[ $first -eq 0 ]] && echo ","
first=0
printf ' "%s": { "sha": "%s", "tag": "%s-%s" }' \
"$shell_name" "$sha" "$shell_name" "${sha:0:12}"
done
echo ""
echo " }"
echo "}"
} > staging/manifest.json
cat staging/manifest.json
# Pull every per-SHA asset for every platform. If a per-SHA tag is
# missing (e.g. this run was --only=<subset> so other shells were
# never built at this SHA), fall back to the existing 'latest'
# release's copy — avoids a partial rebuild silently orphaning
# unrelated shells from the rolling release.
downloaded=0
preserved=0
missing=()
for shell_name in openclaw zeroclaw nanoclaw nemoclaw mirofish picoclaw hermes openfang; do
sha="${SHAS[$shell_name]}"
[[ -z "$sha" ]] && continue
tag="${shell_name}-${sha:0:12}"
for target in darwin-arm64 darwin-amd64 linux-amd64 linux-arm64 windows-amd64; do
ext=""
[[ "$target" == "windows-amd64" ]] && ext=".exe"
asset="${shell_name}-${target}${ext}"
if gh release download "$tag" --pattern "$asset" \
--output "staging/$asset" --clobber 2>/dev/null; then
size=$(wc -c < "staging/$asset" | tr -d ' ')
echo " ✓ $asset ($size bytes) ← $tag"
downloaded=$((downloaded + 1))
elif gh release download latest --pattern "$asset" \
--output "staging/$asset" --clobber 2>/dev/null; then
size=$(wc -c < "staging/$asset" | tr -d ' ')
echo " ↺ $asset ($size bytes) ← previous latest (tag $tag not published)"
preserved=$((preserved + 1))
else
missing+=("$asset")
fi
done
done
echo ""
echo "Downloaded $downloaded new + preserved $preserved from prior latest; ${#missing[@]} missing"
# Don't fail consolidation on missing assets — publish whatever made
# it through this build cycle. Empty staging is still a real failure.
if [[ $((downloaded + preserved)) -eq 0 ]]; then
echo "::error::no per-SHA assets to consolidate and no prior latest to preserve from"
exit 1
fi
# Compose stable release notes with current SHA → tag table.
notes="$RUNNER_TEMP/latest-notes.md"
{
echo "# Latest VIMS agent shell runtimes"
echo ""
echo "Rolling release. **Always contains the most recent successful build of each shell × platform.**"
echo "For reproducible pinning, use the per-SHA prerelease tags listed below."
echo ""
echo "Updated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
echo "## Current upstream commits"
echo ""
echo "| Shell | Upstream SHA | Pin tag |"
echo "|-------|--------------|---------|"
for shell_name in openclaw zeroclaw nanoclaw nemoclaw mirofish picoclaw hermes openfang; do
sha="${SHAS[$shell_name]}"
[[ -z "$sha" ]] && continue
echo "| \`$shell_name\` | \`${sha:0:12}\` | [\`${shell_name}-${sha:0:12}\`](https://github.com/$GH_REPO/releases/tag/${shell_name}-${sha:0:12}) |"
done
echo ""
echo "## Download"
echo ""
echo '```bash'
echo "# Pick your platform; replace SHELL with one of the eight names above."
echo "curl -fL -o openclaw \\"
echo " https://github.com/$GH_REPO/releases/download/latest/openclaw-darwin-arm64"
echo '```'
echo ""
echo "Or fetch the JSON manifest:"
echo ""
echo '```bash'
echo "curl -fL https://github.com/$GH_REPO/releases/download/latest/manifest.json"
echo '```'
if [[ ${#missing[@]} -gt 0 ]]; then
echo ""
echo "<details><summary>${#missing[@]} asset(s) missing from this build cycle</summary>"
echo ""
for m in "${missing[@]}"; do echo "- \`$m\`"; done
echo ""
echo "</details>"
fi
} > "$notes"
# Recreate the 'latest' release each cycle so its asset list reflects
# only the current cycle's binaries — `gh release upload --clobber`
# alone would leave stale platforms in place if a target was dropped.
if gh release view latest >/dev/null 2>&1; then
gh release delete latest --yes --cleanup-tag || true
fi
gh release create latest staging/* \
--title "Latest runtimes (rolling)" \
--notes-file "$notes" \
--latest=true
echo "::notice::published 'latest' release with $(ls staging | wc -l | tr -d ' ') asset(s)"