From 1299b8a29419a9ce0106720e0b5f0262356a4ac9 Mon Sep 17 00:00:00 2001 From: Vivek Agarwal Date: Sat, 11 Jul 2026 13:55:39 +0530 Subject: [PATCH 1/6] ci: add GitHub Actions CI + auto-deploy to GCP VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml: fast gate on push/PR — frontend pnpm build + deterministic backend `pytest tests/unit` (no Gemini key → real-LLM tests auto-skip) + alembic migration smoke. - ci-full.yml: full real-Gemini suite, nightly + manual. - deploy.yml: on merge to main — build frontend, bundle app, scp to the ir-civil-agent VM, write .env from secrets, uv sync + alembic upgrade, restart the ir-agent systemd service (auth via gcloud service account). - deploy/: bootstrap.sh (one-time VM setup), ir-agent.service (systemd), deploy-remote.sh (on-VM deploy step), README.md (secrets/vars + steps). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-full.yml | 29 ++++++++ .github/workflows/ci.yml | 57 ++++++++++++++++ .github/workflows/deploy.yml | 70 ++++++++++++++++++++ deploy/README.md | 121 ++++++++++++++++++++++++++++++++++ deploy/bootstrap.sh | 71 ++++++++++++++++++++ deploy/deploy-remote.sh | 34 ++++++++++ deploy/ir-agent.service | 18 +++++ 7 files changed, 400 insertions(+) create mode 100644 .github/workflows/ci-full.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 deploy/README.md create mode 100644 deploy/bootstrap.sh create mode 100644 deploy/deploy-remote.sh create mode 100644 deploy/ir-agent.service diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml new file mode 100644 index 0000000..480ab46 --- /dev/null +++ b/.github/workflows/ci-full.yml @@ -0,0 +1,29 @@ +name: CI (full, real Gemini) + +# The complete suite INCLUDING the integration tests that call the real Gemini +# API (per CLAUDE.md: "tests run against the real LLM using keys from .env"). +# This burns tokens, so it is NOT wired to every push — it runs nightly and +# on-demand. Requires the AGENT_GEMINI_API_KEY repo secret. + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" # 03:00 UTC nightly + +jobs: + full-suite: + name: Full pytest suite (real Gemini) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System libs for OpenCASCADE / matplotlib + run: sudo apt-get update && sudo apt-get install -y libgl1 libglu1-mesa + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install deps (uv sync) + run: uv sync --dev + - name: Full suite + run: uv run pytest -q + env: + AGENT_GEMINI_API_KEY: ${{ secrets.AGENT_GEMINI_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2af06a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +# Fast, dependency-free gate: frontend build + deterministic backend tests. +# The real-Gemini integration suite is intentionally NOT run here (it costs +# tokens on every push). It runs in ci-full.yml (nightly + manual). LLM tests +# auto-skip when AGENT_GEMINI_API_KEY is unset (tests/conftest.py::_require_llm_key +# and tests/integration/conftest.py::require_gemini), so simply not providing the +# key gives a clean deterministic gate. + +on: + push: + branches: [main, "cicd/**"] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + frontend: + name: Frontend build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm build + + backend: + name: Backend tests (deterministic, no Gemini key) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System libs for OpenCASCADE / matplotlib + run: sudo apt-get update && sudo apt-get install -y libgl1 libglu1-mesa + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install deps (uv sync) + run: uv sync --dev + - name: Deterministic unit tests + # No AGENT_GEMINI_API_KEY in env -> every real-LLM test auto-skips. + run: uv run pytest tests/unit -q + - name: Migration smoke (alembic upgrade head) + run: | + mkdir -p data + uv run alembic upgrade head + env: + AGENT_DATABASE_URL: sqlite:///./data/ci.db diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a496c54 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,70 @@ +name: Deploy to VM + +# On merge to main: build the frontend, bundle the app, ship it to the GCP VM, +# write the VM's .env from secrets, install deps + run migrations, restart the +# systemd service. The VM must be bootstrapped once first (see deploy/README.md). + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-vm + cancel-in-progress: false # never interrupt an in-flight deploy + +jobs: + deploy: + name: Build + deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # ---- build the static frontend (never build on the 2 GB VM) ---- + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - name: Build frontend + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + # ---- bundle backend + built frontend ---- + - name: Create deploy bundle + run: tar czf bundle.tar.gz src alembic alembic.ini pyproject.toml uv.lock frontend/out + + - name: Write .env for the VM + run: | + umask 077 + cat > deploy.env < ⚠️ `key.json` is a credential — delete it after uploading (`rm key.json`). Never commit it. + +### 3. Open the app firewall port (once) + +```bash +gcloud compute firewall-rules create allow-ir-civil-agent-8001 \ + --allow tcp:8001 --project ai-agent-boilerplate0 \ + --description "IR agent HTTP" +``` + +### 4. Bootstrap the VM (once) + +```bash +gcloud compute scp deploy/bootstrap.sh ir-civil-agent:~ \ + --zone us-central1-b --project ai-agent-boilerplate0 +gcloud compute ssh ir-civil-agent \ + --zone us-central1-b --project ai-agent-boilerplate0 \ + --command 'sudo bash bootstrap.sh' +``` + +This adds 2 GB swap, installs system libs + `uv`, creates the `iragent` service +user and `/opt/ir-agent`, and installs (enables, does not start) the +`ir-agent` systemd unit. + +--- + +## Deploy + +Any push to `main` (typically merging your PR) runs `deploy.yml`. To deploy +by hand: Actions → **Deploy to VM** → Run workflow. + +Once green, the app is live at **`http://:8001/app/`** +(`gcloud compute instances list` for the IP). + +## Operate (on the VM) + +```bash +sudo systemctl status ir-agent # health +sudo journalctl -u ir-agent -f # live logs +sudo systemctl restart ir-agent # manual restart +``` + +## Notes / gotchas + +- **HTTP only** — the app is served on `:8001` over plain HTTP against the VM's + IP (fine for a same-origin demo). No TLS/domain wired. Don't put the Gemini + key anywhere client-facing. +- **First deploy is slow** — `uv sync` pulls OpenCASCADE (`build123d`) wheels + (~1 GB). Later deploys reuse the cached venv. +- **SSH auth** — if `gcloud compute ssh` from the runner can't reach port 22, + add `--tunnel-through-iap` to the scp/ssh steps in `deploy.yml` and grant the + SA `roles/iap.tunnelResourceAccessor`. +- **Teardown after the demo** — `gcloud compute instances delete ir-civil-agent + --zone us-central1-b` to stop billing. diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100644 index 0000000..974c567 --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# One-time VM setup for the IR Civil Engineer Agent. Run ONCE on a fresh VM as a +# sudo-capable user: +# +# gcloud compute scp deploy/bootstrap.sh ir-civil-agent:~ \ +# --zone us-central1-b --project ai-agent-boilerplate0 +# gcloud compute ssh ir-civil-agent \ +# --zone us-central1-b --project ai-agent-boilerplate0 \ +# --command 'sudo bash bootstrap.sh' +# +# Idempotent: safe to re-run. It does NOT start the service — the first +# successful "Deploy to VM" GitHub Action ships the code and starts it. +set -euo pipefail + +APP_DIR=/opt/ir-agent +SERVICE=ir-agent +SERVICE_USER=iragent + +echo "==> 1/5 Swap (2 GB, idempotent)" +if [ ! -f /swapfile ]; then + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi + +echo "==> 2/5 System packages (git, python, OpenCASCADE/matplotlib libs)" +apt-get update +apt-get install -y git python3.11 python3.11-venv libgl1 libglu1-mesa ca-certificates curl + +echo "==> 3/5 uv (system-wide at /usr/local/bin)" +if ! command -v /usr/local/bin/uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh \ + | env UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh +fi + +echo "==> 4/5 Service user + app dir" +id -u "${SERVICE_USER}" >/dev/null 2>&1 \ + || useradd --system --create-home --shell /usr/sbin/nologin "${SERVICE_USER}" +mkdir -p "${APP_DIR}/data/artifacts" +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}" + +echo "==> 5/5 systemd unit" +cat > "/etc/systemd/system/${SERVICE}.service" <<'UNIT' +[Unit] +Description=IR Civil Engineer Agent (FastAPI + LangGraph) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=iragent +Group=iragent +WorkingDirectory=/opt/ir-agent +ExecStart=/usr/local/bin/uv run python -m src +Restart=on-failure +RestartSec=5 +Environment=PATH=/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable "${SERVICE}" + +echo "" +echo "==> Bootstrap complete." +echo " The service is enabled but NOT started (no code yet)." +echo " Push to main (or run the 'Deploy to VM' workflow) to ship + start it." +echo " App will serve at: http://:8001/app/" diff --git a/deploy/deploy-remote.sh b/deploy/deploy-remote.sh new file mode 100644 index 0000000..ef78465 --- /dev/null +++ b/deploy/deploy-remote.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Runs ON THE VM (as root, via `sudo bash /tmp/deploy-remote.sh`), invoked by +# .github/workflows/deploy.yml. Expects /tmp/bundle.tar.gz, /tmp/deploy.env, and +# a bootstrapped host (see bootstrap.sh: iragent user, /opt/ir-agent, uv, unit). +set -euo pipefail + +APP_DIR=/opt/ir-agent +SERVICE=ir-agent +SERVICE_USER=iragent + +echo "==> Unpacking bundle into ${APP_DIR}" +mkdir -p "${APP_DIR}" +tar xzf /tmp/bundle.tar.gz -C "${APP_DIR}" + +echo "==> Installing .env (0600, owned by ${SERVICE_USER})" +install -o "${SERVICE_USER}" -g "${SERVICE_USER}" -m 600 /tmp/deploy.env "${APP_DIR}/.env" + +echo "==> Fixing ownership" +mkdir -p "${APP_DIR}/data/artifacts" +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}" + +echo "==> uv sync (prod deps) + alembic upgrade" +sudo -u "${SERVICE_USER}" --set-home env PATH=/usr/local/bin:/usr/bin:/bin \ + bash -lc "cd ${APP_DIR} && uv sync --no-dev && uv run alembic upgrade head" + +echo "==> Restarting ${SERVICE}" +systemctl restart "${SERVICE}" +sleep 2 +systemctl --no-pager --lines=10 status "${SERVICE}" || true + +echo "==> Cleaning up /tmp" +rm -f /tmp/bundle.tar.gz /tmp/deploy.env /tmp/deploy-remote.sh + +echo "==> Deploy complete." diff --git a/deploy/ir-agent.service b/deploy/ir-agent.service new file mode 100644 index 0000000..821e7b2 --- /dev/null +++ b/deploy/ir-agent.service @@ -0,0 +1,18 @@ +[Unit] +Description=IR Civil Engineer Agent (FastAPI + LangGraph) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=iragent +Group=iragent +WorkingDirectory=/opt/ir-agent +# pydantic-settings reads /opt/ir-agent/.env from the working directory. +ExecStart=/usr/local/bin/uv run python -m src +Restart=on-failure +RestartSec=5 +Environment=PATH=/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=multi-user.target From b5d5b36d06275f055825ff93c8c89620de6e9fd6 Mon Sep 17 00:00:00 2001 From: Vivek Agarwal Date: Sat, 11 Jul 2026 13:56:23 +0530 Subject: [PATCH 2/6] ci: enforce LF for deploy scripts via .gitattributes Shell scripts and the systemd unit run on the Linux VM; CRLF would break them regardless of a contributor's local core.autocrlf. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..38a57d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell scripts + systemd units must stay LF — they run on the Linux VM and +# would break (bad interpreter / `\r` in commands) with CRLF line endings, +# regardless of a contributor's local core.autocrlf setting. +*.sh text eol=lf +*.service text eol=lf +deploy/** text eol=lf From a21231a2d378ab899d41b796041c322e310baef8 Mon Sep 17 00:00:00 2001 From: Vivek Agarwal Date: Sat, 11 Jul 2026 15:00:39 +0530 Subject: [PATCH 3/6] ci: read GCP_SA_KEY + AGENT_GEMINI_API_KEY from vars, not secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy config stores these as repository variables (user choice), so reference them via vars.* — secrets.* would resolve empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-full.yml | 2 +- .github/workflows/deploy.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 480ab46..7302914 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -26,4 +26,4 @@ jobs: - name: Full suite run: uv run pytest -q env: - AGENT_GEMINI_API_KEY: ${{ secrets.AGENT_GEMINI_API_KEY }} + AGENT_GEMINI_API_KEY: ${{ vars.AGENT_GEMINI_API_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a496c54..18271ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,7 +43,7 @@ jobs: run: | umask 077 cat > deploy.env < Date: Sat, 11 Jul 2026 15:06:32 +0530 Subject: [PATCH 4/6] ci: don't apt-install python3.11 in bootstrap (uv provides Python) The VM base image (Ubuntu questing) has no python3.11 package, and uv fetches a standalone CPython >=3.11 per pyproject anyway. Keeps bootstrap independent of the base image's Python version. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/bootstrap.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 974c567..3913f30 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -25,9 +25,11 @@ if [ ! -f /swapfile ]; then grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab fi -echo "==> 2/5 System packages (git, python, OpenCASCADE/matplotlib libs)" +echo "==> 2/5 System packages (git, OpenCASCADE/matplotlib libs)" +# Python itself is NOT installed here — uv fetches a standalone CPython (>=3.11) +# per pyproject. This keeps us independent of the base image's Python version. apt-get update -apt-get install -y git python3.11 python3.11-venv libgl1 libglu1-mesa ca-certificates curl +apt-get install -y git libgl1 libglu1-mesa ca-certificates curl echo "==> 3/5 uv (system-wide at /usr/local/bin)" if ! command -v /usr/local/bin/uv >/dev/null 2>&1; then From e627d84696bb2b78918491d5a45a67d0ce9aa127 Mon Sep 17 00:00:00 2001 From: Vivek Agarwal Date: Sat, 11 Jul 2026 15:09:41 +0530 Subject: [PATCH 5/6] ci: pin pnpm to 11 (matches frontend/pnpm-workspace.yaml settings format) frontend/pnpm-workspace.yaml uses the pnpm v10+ settings schema (allowBuilds, no packages field). pnpm 9 rejected it with "packages field missing or empty". Pin to 11 to match the local toolchain + lockfile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2af06a..665532e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 9 + version: 11 - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 18271ce..c5882c2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,7 +23,7 @@ jobs: # ---- build the static frontend (never build on the 2 GB VM) ---- - uses: pnpm/action-setup@v4 with: - version: 9 + version: 11 - uses: actions/setup-node@v4 with: node-version: 20 From c5a2c84f0d4be47d8b3342ee787d2959d3c776cf Mon Sep 17 00:00:00 2001 From: Vivek Agarwal Date: Sat, 11 Jul 2026 15:12:02 +0530 Subject: [PATCH 6/6] ci: use Node 24 (pnpm 11 needs node:sqlite, absent in Node 20) pnpm 11 requires the node:sqlite built-in (Node 22+). Node 20 crashed with ERR_UNKNOWN_BUILTIN_MODULE. Node 24 also clears the runner's Node 20 deprecation warnings; Next 15 builds fine on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 665532e..30ab06a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: version: 11 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c5882c2..4682a05 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,7 +26,7 @@ jobs: version: 11 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - name: Build frontend