Skip to content

Commit 141254c

Browse files
AhmedTMMclaude
andauthored
feat: ARM tarball builds + arch-aware download (#2248)
* feat: ARM tarball builds + arch-aware download - Add ARM64 matrix entries for native binary agents (zeroclaw, opencode, hermes, claude) in agent-tarballs.yml workflow - Update agent-tarball.ts to detect remote VM arch via uname -m and download the correct tarball (x86_64 or arm64) - Change release strategy to support multiple arch assets per tag - Document ARM build requirements in discovery.md for future agents - Bump CLI version to 0.15.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use sudo for tarball extraction on non-root SSH clouds On AWS Lightsail, SSH connects as 'ubuntu' (not root), but tarballs extract to /root/. Without sudo, tar fails with "Permission denied". Conditionally use sudo when not running as root (id -u != 0). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5541295 commit 141254c

4 files changed

Lines changed: 70 additions & 22 deletions

File tree

.claude/rules/discovery.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ Do NOT add agents speculatively. Only add one if there's **real community buzz**
5959
- Accepts API keys via env vars (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENROUTER_API_KEY`)
6060
- Works with OpenRouter (natively or via `OPENAI_BASE_URL` override)
6161

62+
**ARM builds for native binary agents:**
63+
Agents that ship compiled binaries (Rust, Go, etc.) need separate ARM (aarch64) tarball builds. npm-based agents are arch-independent and only need x86_64 builds. When adding a new agent:
64+
- If it installs via `npm install -g` → x86_64 tarball only (Node handles arch)
65+
- If it installs a pre-compiled binary (curl download, cargo install, go install) → add an ARM entry in `.github/workflows/agent-tarballs.yml` matrix `include` section
66+
- Current native binary agents needing ARM: zeroclaw (Rust), opencode (Go), hermes, claude
67+
6268
To add: same steps as before (manifest.json entry, matrix entries, implement on 1+ cloud, README).
6369

6470
## 4. Respond to GitHub issues

.github/workflows/agent-tarballs.yml

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,24 @@ jobs:
3939
4040
build:
4141
needs: matrix
42-
runs-on: ubuntu-latest
42+
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
4343
strategy:
44-
max-parallel: 3
44+
max-parallel: 4
4545
fail-fast: false
4646
matrix:
4747
agent: ${{ fromJson(needs.matrix.outputs.agents) }}
48+
arch: [x86_64]
49+
# Native-binary agents need ARM builds too.
50+
# npm-based agents (codex, openclaw, kilocode) are arch-independent — x86_64 only.
51+
include:
52+
- agent: zeroclaw
53+
arch: arm64
54+
- agent: opencode
55+
arch: arm64
56+
- agent: hermes
57+
arch: arm64
58+
- agent: claude
59+
arch: arm64
4860
steps:
4961
- uses: actions/checkout@v4
5062

@@ -132,17 +144,29 @@ jobs:
132144
set -eo pipefail
133145
TAG="agent-${AGENT_NAME}-latest"
134146
DATE=$(date -u +%Y%m%d)
135-
TARBALL="spawn-agent-${AGENT_NAME}-x86_64-${DATE}.tar.gz"
147+
ARCH="${{ matrix.arch }}"
148+
TARBALL="spawn-agent-${AGENT_NAME}-${ARCH}-${DATE}.tar.gz"
136149
137150
# Move tarball to expected name (tarball is owned by root from sudo capture)
138151
sudo mv "/tmp/spawn-agent-${AGENT_NAME}.tar.gz" "${TARBALL}"
139152
sudo chown "$(id -u):$(id -g)" "${TARBALL}"
140153
141-
# Delete existing release if present (rolling release)
142-
gh release delete "${TAG}" --yes 2>/dev/null || true
154+
# Create release if it doesn't exist, then upload the arch-specific tarball.
155+
# Multiple arch builds (x86_64, arm64) upload to the same release.
156+
if ! gh release view "${TAG}" > /dev/null 2>&1; then
157+
gh release create "${TAG}" \
158+
--title "Agent tarball: ${AGENT_NAME} (${DATE})" \
159+
--notes "Pre-built tarball for \`${AGENT_NAME}\` agent. Auto-generated nightly." \
160+
--prerelease
161+
fi
162+
163+
# Delete stale asset for this arch if present (from a previous build today)
164+
gh release delete-asset "${TAG}" "${TARBALL}" --yes 2>/dev/null || true
165+
# Also clean up any older-dated tarball for this arch
166+
gh release view "${TAG}" --json assets --jq ".assets[].name" 2>/dev/null \
167+
| grep "spawn-agent-${AGENT_NAME}-${ARCH}-" \
168+
| while IFS= read -r old; do
169+
gh release delete-asset "${TAG}" "${old}" --yes 2>/dev/null || true
170+
done
143171
144-
# Create fresh release with the tarball
145-
gh release create "${TAG}" "${TARBALL}" \
146-
--title "Agent tarball: ${AGENT_NAME} (${DATE})" \
147-
--notes "Pre-built tarball for \`${AGENT_NAME}\` agent. Auto-generated nightly." \
148-
--prerelease
172+
gh release upload "${TAG}" "${TARBALL}"

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.15.1",
3+
"version": "0.15.2",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"

packages/cli/src/shared/agent-tarball.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,30 +54,48 @@ export async function tryTarballInstall(
5454
return false;
5555
}
5656

57-
// Find the .tar.gz asset
58-
const asset = parsed.output.assets.find((a) => a.name.endsWith(".tar.gz"));
59-
if (!asset) {
57+
// Find both arch-specific .tar.gz assets and let the remote VM pick the right one.
58+
// We try x86_64 first (most common), and include arm64 fallback in the remote script.
59+
const x86Asset = parsed.output.assets.find((a) => a.name.includes("-x86_64-") && a.name.endsWith(".tar.gz"));
60+
const armAsset = parsed.output.assets.find((a) => a.name.includes("-arm64-") && a.name.endsWith(".tar.gz"));
61+
62+
if (!x86Asset && !armAsset) {
6063
logWarn("No tarball asset found in release");
6164
return false;
6265
}
6366

64-
const url = asset.browser_download_url;
67+
// Build arch-aware download: remote VM detects its own arch and picks the right URL
68+
const x86Url = x86Asset?.browser_download_url || "";
69+
const armUrl = armAsset?.browser_download_url || "";
70+
const url = x86Url || armUrl;
6571

66-
// SECURITY: Validate URL matches expected GitHub releases pattern.
72+
// SECURITY: Validate URLs match expected GitHub releases pattern.
6773
// Prevents shell injection via crafted API responses.
68-
if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/releases\/download\/[^\s'"`;|&$()]+$/.test(url)) {
74+
const urlPattern = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/releases\/download\/[^\s'"`;|&$()]+$/;
75+
if ((x86Url && !urlPattern.test(x86Url)) || (armUrl && !urlPattern.test(armUrl))) {
6976
logWarn("Tarball URL failed safety validation");
7077
return false;
7178
}
7279

7380
logStep("Downloading pre-built agent tarball...");
7481

82+
// Build arch-aware download command: remote VM picks the right URL based on uname -m
83+
// Use sudo for tar extraction — on clouds like AWS Lightsail, SSH user is 'ubuntu' (non-root)
84+
// but tarballs extract to /root/. The ubuntu user has passwordless sudo.
85+
const sudo = '$([ "$(id -u)" != "0" ] && echo sudo || echo "")';
86+
let downloadCmd: string;
87+
if (x86Url && armUrl) {
88+
downloadCmd =
89+
"_arch=$(uname -m); " +
90+
`if [ "$_arch" = "aarch64" ] || [ "$_arch" = "arm64" ]; then ` +
91+
`_url='${armUrl}'; else _url='${x86Url}'; fi; ` +
92+
`curl -fsSL --connect-timeout 10 --max-time 120 "$_url" | ${sudo} tar xz -C / && ${sudo} test -f /root/.spawn-tarball`;
93+
} else {
94+
downloadCmd = `curl -fsSL --connect-timeout 10 --max-time 120 '${url}' | ${sudo} tar xz -C / && ${sudo} test -f /root/.spawn-tarball`;
95+
}
96+
7597
// Download and extract on the remote VM
76-
// --connect-timeout 10s, --max-time 120s, -L to follow redirects (GitHub releases redirect)
77-
await runner.runServer(
78-
`curl -fsSL --connect-timeout 10 --max-time 120 '${url}' | tar xz -C / && [ -f /root/.spawn-tarball ]`,
79-
150, // 2.5 min total timeout for the SSH command
80-
);
98+
await runner.runServer(downloadCmd, 150);
8199

82100
logInfo("Agent installed from pre-built tarball");
83101
return true;

0 commit comments

Comments
 (0)