name: deploy # Fully autonomous, idempotent deploy. On every push to main (or manual # dispatch) this builds the Go binaries ON THE RUNNER (which has plenty of # RAM), ships them to the bbs.profullstack.com droplet, and re-runs the # idempotent provisioner (setup.sh) with SKIP_BUILD=1 so the tiny droplet # never has to compile. setup.sh still pulls origin, refreshes config/assets, # and restarts the agentbbs service that answers `ssh join@bbs.profullstack.com`. # Re-running is always safe. # # Why build on the runner: the droplet is a ~458MB box also running ergo, # forgejo, tor, podman and the live agentbbs. The Go linker's peak memory was # OOM-killing the build — and with it the sshd serving the deploy session, # surfacing as "Connection closed by remote host" (exit 255). Compiling on the # 16GB runner removes that failure mode entirely. # # Required repo secrets (Settings -> Secrets and variables -> Actions): # DEPLOY_SSH_KEY private key whose public half is in the droplet admin # user's ~/.ssh/authorized_keys # DEPLOY_HOST bbs.profullstack.com (or the droplet's public IP) # Optional (have sensible defaults below): # DEPLOY_USER admin SSH user (default: root) # DEPLOY_PORT admin SSH port (default: 2202 — setup.sh moves OpenSSH here) # # The admin user needs passwordless sudo (root already does). on: push: branches: [main, master] workflow_dispatch: # Never let two deploys overlap; setup.sh also self-locks, this is belt+braces. concurrency: group: deploy-production cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Configure SSH env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} run: | test -n "$DEPLOY_SSH_KEY" || { echo "::error::DEPLOY_SSH_KEY secret is not set"; exit 1; } test -n "$DEPLOY_HOST" || { echo "::error::DEPLOY_HOST secret is not set"; exit 1; } install -d -m 700 ~/.ssh printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null - name: Detect droplet architecture id: arch env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} run: | uname_m="$(ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ "${DEPLOY_USER}@${DEPLOY_HOST}" 'uname -m')" case "$uname_m" in x86_64|amd64) goarch=amd64 ;; aarch64|arm64) goarch=arm64 ;; *) echo "::error::unsupported droplet arch '$uname_m'"; exit 1 ;; esac echo "goarch=$goarch" >> "$GITHUB_OUTPUT" echo "::notice::droplet arch $uname_m -> GOARCH=$goarch" - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: Build binaries (on the runner, not the droplet) env: GOOS: linux GOARCH: ${{ steps.arch.outputs.goarch }} CGO_ENABLED: '0' # pure-Go (modernc sqlite) — static, portable binary run: | mkdir -p dist go build -trimpath -o dist/agentbbs ./cmd/agentbbs go build -trimpath -o dist/ascii-live ./cmd/ascii-live file dist/* || true - name: Ship binaries to the droplet env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} run: | # scp can only name one remote target; copy each binary explicitly. scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ dist/agentbbs "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-agentbbs" scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ dist/ascii-live "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-ascii-live" - name: Provision / redeploy (idempotent, SKIP_BUILD=1) env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} # Deploy whichever branch was pushed (main or master), so a rename # "just works". For workflow_dispatch this is the chosen branch. DEPLOY_BRANCH: ${{ github.ref_name }} # App secrets injected into the droplet's env file by setup.sh. # GitHub masks these in logs; setup.sh upserts them idempotently. COINPAY_API_KEY: ${{ secrets.COINPAY_API_KEY }} COINPAY_MERCHANT_ID: ${{ secrets.COINPAY_MERCHANT_ID }} AGENTBBS_QRYPT_ISSUER_KEY: ${{ secrets.AGENTBBS_QRYPT_ISSUER_KEY }} run: | ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ "sudo -n env BRANCH=$(printf %q "$DEPLOY_BRANCH") \ COINPAY_API_KEY=$(printf %q "$COINPAY_API_KEY") \ COINPAY_MERCHANT_ID=$(printf %q "$COINPAY_MERCHANT_ID") \ AGENTBBS_QRYPT_ISSUER_KEY=$(printf %q "$AGENTBBS_QRYPT_ISSUER_KEY") \ bash -s" <<'REMOTE' set -euo pipefail REPO=https://github.com/profullstack/agentbbs.git BRANCH="${BRANCH:-main}" SRC=/opt/agentbbs # Bootstrap on a fresh box, then always sync to origin so we run the # latest setup.sh (it may have changed in this very push). if [ ! -d "$SRC/.git" ]; then git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC" fi git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" reset --hard "origin/$BRANCH" # Preempt the self-update timer. It redeploys from source (no # SKIP_BUILD) and can hold setup.sh's flock for >5min while compiling # on this tiny droplet, which would starve and time out the deploy # below. This CI push is authoritative (prebuilt binaries + reset to # the exact commit), so stop any in-flight timer run to release the # lock and pause the timer so it can't re-fire mid-deploy; setup.sh # re-enables the timer at the end of its run. systemctl stop agentbbs-update.timer agentbbs-update.service 2>/dev/null || true # Install the runner-built binaries, then tell setup.sh not to compile. install -m 0755 /tmp/agentbbs-deploy-agentbbs /usr/local/bin/agentbbs install -m 0755 /tmp/agentbbs-deploy-ascii-live /usr/local/bin/ascii-live rm -f /tmp/agentbbs-deploy-agentbbs /tmp/agentbbs-deploy-ascii-live exec env BRANCH="$BRANCH" SKIP_BUILD=1 \ COINPAY_API_KEY="${COINPAY_API_KEY:-}" \ COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \ "$SRC/setup.sh" REMOTE - name: Smoke-test that agentbbs serves :22 if: success() env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} run: | # Confirm an SSH server answers on :22 WITHOUT authenticating — a real # join@ connection would register the connecting key as a new account, # so we never complete a handshake here. Admin OpenSSH lives on the # admin port, so anything serving :22 is agentbbs answering join@/bbs@. if timeout 15 ssh-keyscan -T 10 -p 22 "$DEPLOY_HOST" 2>/dev/null | grep -q .; then echo "::notice::agentbbs is serving SSH on ${DEPLOY_HOST}:22 (join@ is reachable)" else echo "::error::nothing is serving SSH on ${DEPLOY_HOST}:22 — agentbbs may be down" exit 1 fi