Build Agent Shell Runtimes #29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)" |