Agent Tarballs #14
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: Agent Tarballs | |
| on: | |
| schedule: | |
| # 5 AM UTC daily | |
| - cron: "0 5 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| agent: | |
| description: "Single agent to build (leave empty for all)" | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| jobs: | |
| matrix: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| agents: ${{ steps.set-matrix.outputs.agents }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - id: set-matrix | |
| env: | |
| AGENT_INPUT: ${{ inputs.agent }} | |
| run: | | |
| if [ -n "${AGENT_INPUT:-}" ]; then | |
| # Validate: agent name must be alphanumeric/hyphens only | |
| if ! printf '%s' "${AGENT_INPUT}" | grep -qE '^[a-z][a-z0-9-]*$'; then | |
| echo "::error::Invalid agent name: ${AGENT_INPUT}" | |
| exit 1 | |
| fi | |
| echo "agents=$(jq -cn --arg a "${AGENT_INPUT}" '[$a]')" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "agents=$(jq -c 'keys' packer/agents.json)" >> "$GITHUB_OUTPUT" | |
| fi | |
| build: | |
| needs: matrix | |
| runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} | |
| strategy: | |
| max-parallel: 4 | |
| fail-fast: false | |
| matrix: | |
| agent: ${{ fromJson(needs.matrix.outputs.agents) }} | |
| arch: [x86_64] | |
| # Native-binary agents need ARM builds too. | |
| # npm-based agents (codex, openclaw, kilocode) are arch-independent — x86_64 only. | |
| include: | |
| - agent: zeroclaw | |
| arch: arm64 | |
| - agent: opencode | |
| arch: arm64 | |
| - agent: hermes | |
| arch: arm64 | |
| - agent: claude | |
| arch: arm64 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install agent under /root | |
| env: | |
| AGENT_NAME: ${{ matrix.agent }} | |
| run: | | |
| set -eo pipefail | |
| # Validate agent exists in agents.json (prevents path traversal / injection) | |
| if ! jq -e --arg a "${AGENT_NAME}" 'has($a)' packer/agents.json > /dev/null; then | |
| echo "::error::Unknown agent: ${AGENT_NAME}" | |
| exit 1 | |
| fi | |
| # Read the agent's tier from packer/agents.json | |
| TIER=$(jq -r --arg a "${AGENT_NAME}" '.[$a].tier' packer/agents.json) | |
| echo "==> Agent: ${AGENT_NAME}, Tier: ${TIER}" | |
| # Run tier script (sets up node/bun/etc. under /root) | |
| if [ -f "packer/scripts/tier-${TIER}.sh" ]; then | |
| echo "==> Running tier script: tier-${TIER}.sh" | |
| sudo HOME=/root bash "packer/scripts/tier-${TIER}.sh" | |
| fi | |
| # TRUST BOUNDARY: packer/agents.json is version-controlled and requires | |
| # PR review to modify. Install commands are executed via bash -c, so any | |
| # change to agents.json MUST be reviewed carefully for command safety. | |
| # | |
| # Security layers: | |
| # 1. agents.json changes require PR review (branch protection) | |
| # 2. curl/wget targets validated against domain allowlist | |
| # 3. Suspicious command patterns rejected via blocklist | |
| # 4. Runs in ephemeral GitHub Actions container (destroyed after each run) | |
| echo "==> Installing agent..." | |
| # Allowed domains for curl/wget downloads (official agent vendor domains) | |
| ALLOWED_DOMAINS="claude.ai|opencode.ai|raw.githubusercontent.com|registry.npmjs.org|crates.io|github.com|dl.google.com" | |
| CMD_COUNT=$(jq -r --arg a "${AGENT_NAME}" '.[$a].install | length' packer/agents.json) | |
| i=0 | |
| while [ "$i" -lt "$CMD_COUNT" ]; do | |
| cmd=$(jq -r --arg a "${AGENT_NAME}" --argjson i "$i" '.[$a].install[$i]' packer/agents.json) | |
| # Safety layer 1: reject suspicious command patterns | |
| if printf '%s' "${cmd}" | grep -qE '(mktemp|eval |base64 -d|/dev/tcp|nc -[elp]|python[23]? -c|perl -e|ruby -e|\bdd\b|>[[:space:]]*/dev/|rm -rf)'; then | |
| echo "::error::Suspicious install command rejected: ${cmd}" | |
| exit 1 | |
| fi | |
| # Safety layer 2: validate curl/wget URLs against domain allowlist | |
| if printf '%s' "${cmd}" | grep -qE '(curl|wget)'; then | |
| urls=$(printf '%s' "${cmd}" | grep -oE 'https?://[^[:space:]"|'\'']+' || true) | |
| for url in ${urls}; do | |
| domain=$(printf '%s' "${url}" | sed -E 's|^https?://([^/]+).*|\1|') | |
| if ! printf '%s' "${domain}" | grep -qE "^(${ALLOWED_DOMAINS})$"; then | |
| echo "::error::curl/wget to unapproved domain '${domain}' in: ${cmd}" | |
| exit 1 | |
| fi | |
| done | |
| fi | |
| echo "==> Running: ${cmd}" | |
| sudo HOME=/root bash -c "${cmd}" < /dev/null | |
| i=$((i + 1)) | |
| done | |
| - name: Capture agent files into tarball | |
| env: | |
| AGENT_NAME: ${{ matrix.agent }} | |
| run: | | |
| set -eo pipefail | |
| sudo bash packer/scripts/capture-agent.sh "${AGENT_NAME}" | |
| - name: Create or update GitHub Release | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| AGENT_NAME: ${{ matrix.agent }} | |
| run: | | |
| set -eo pipefail | |
| TAG="agent-${AGENT_NAME}-latest" | |
| DATE=$(date -u +%Y%m%d) | |
| ARCH="${{ matrix.arch }}" | |
| TARBALL="spawn-agent-${AGENT_NAME}-${ARCH}-${DATE}.tar.gz" | |
| # Move tarball to expected name (tarball is owned by root from sudo capture) | |
| sudo mv "/tmp/spawn-agent-${AGENT_NAME}.tar.gz" "${TARBALL}" | |
| sudo chown "$(id -u):$(id -g)" "${TARBALL}" | |
| # Create release if it doesn't exist, then upload the arch-specific tarball. | |
| # Multiple arch builds (x86_64, arm64) upload to the same release. | |
| if ! gh release view "${TAG}" > /dev/null 2>&1; then | |
| gh release create "${TAG}" \ | |
| --title "Agent tarball: ${AGENT_NAME} (${DATE})" \ | |
| --notes "Pre-built tarball for \`${AGENT_NAME}\` agent. Auto-generated nightly." \ | |
| --prerelease | |
| fi | |
| # Delete stale asset for this arch if present (from a previous build today) | |
| gh release delete-asset "${TAG}" "${TARBALL}" --yes 2>/dev/null || true | |
| # Also clean up any older-dated tarball for this arch | |
| gh release view "${TAG}" --json assets --jq ".assets[].name" 2>/dev/null \ | |
| | grep "spawn-agent-${AGENT_NAME}-${ARCH}-" \ | |
| | while IFS= read -r old; do | |
| gh release delete-asset "${TAG}" "${old}" --yes 2>/dev/null || true | |
| done | |
| gh release upload "${TAG}" "${TARBALL}" |