Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 62 additions & 15 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,72 @@
name: deploy

# Runs after the `ci` workflow succeeds on main (so a red build never deploys),
# or on demand. The actual build happens on the server: this job only opens an
# SSH session through the Cloudflare tunnel and runs scripts/deploy.sh, which
# git-pulls and rebuilds only the services whose source changed.
# or on demand. Builds the four backend images, pushes them to GHCR, then ships
# the compose + deploy script to the server and pulls the new images there.
on:
workflow_run:
workflows: ["ci"]
types: [completed]
branches: [main]
workflow_dispatch:

# One deploy at a time; never cancel a deploy mid-flight (would risk a
# half-rebuilt stack). Queued runs wait their turn.
# One deploy at a time; never cancel mid-flight (would risk a half-applied
# stack). Queued runs wait their turn.
concurrency:
group: deploy-prod
cancel-in-progress: false

jobs:
deploy:
# Gate: only when the triggering CI run passed (manual dispatch has no
# workflow_run payload, so allow it through too).
build:
# Only on a successful CI run, or manual dispatch (no workflow_run payload).
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
# Only the build job pushes images; deploy needs nothing beyond read.
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- { svc: api, dockerfile: api/Dockerfile }
- { svc: engine, dockerfile: crates/engine/Dockerfile }
- { svc: ingestion, dockerfile: crates/ingestion/Dockerfile }
- { svc: keeper, dockerfile: keeper/Dockerfile }
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build & push ${{ matrix.svc }}
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/volx-${{ matrix.svc }}:latest
ghcr.io/${{ github.repository_owner }}/volx-${{ matrix.svc }}:${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.svc }}
cache-to: type=gha,mode=max,scope=${{ matrix.svc }}

deploy:
needs: [build]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- name: Install cloudflared
uses: AnimMouse/setup-cloudflared@v2

Expand Down Expand Up @@ -55,13 +97,18 @@ jobs:
UserKnownHostsFile ~/.ssh/known_hosts
EOF

- name: Deploy (build only changed services on the server)
- name: Ship compose + deploy script, then pull & apply
env:
DEPLOY_DIR: /home/ancilar/volx-deploy
run: |
ssh ssh.ancilar.com 'bash -se' <<'REMOTE'
set -euo pipefail
cd ~/volx
./scripts/deploy.sh
REMOTE
ssh ssh.ancilar.com "mkdir -p $DEPLOY_DIR"
scp docker/docker-compose.prod.yml ssh.ancilar.com:"$DEPLOY_DIR/docker-compose.prod.yml"
scp docker/clickhouse-init.sql ssh.ancilar.com:"$DEPLOY_DIR/clickhouse-init.sql"
scp scripts/deploy.sh ssh.ancilar.com:"$DEPLOY_DIR/deploy.sh"
# The server already holds $DEPLOY_DIR/.env (keeper secrets), placed
# once by hand. deploy.sh pulls the freshly-pushed images and recreates
# only the services whose digest changed.
ssh ssh.ancilar.com "cd $DEPLOY_DIR && chmod +x deploy.sh && VOLX_TAG=latest ./deploy.sh"

- name: Wipe SSH key
if: always()
Expand Down
38 changes: 27 additions & 11 deletions docker/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@
# - The API IS published, but only on 127.0.0.1:8090, so a host-installed
# cloudflared / local curl still works while staying off the public IP.
#
# Bring up: docker compose -f docker-compose.prod.yml up -d --build
# With containerized tunnel: docker compose -f docker-compose.prod.yml --profile tunnel up -d --build
# The four app services carry both `image:` (registry, pulled on the server)
# and `build:` (local). On the server we only `pull` + `up -d`, so the
# pre-built image is used and no source checkout is needed. Locally,
# `up -d --build` rebuilds from source as before.
#
# Secrets (gitignored, create on the server):
# ../.secrets/sepolia.env SEPOLIA_RPC_URL + PRIVATE_KEY (keeper signer)
# ../.secrets/cloudflared.env TUNNEL_TOKEN (only if using the tunnel profile)
# Server deploy: docker compose -f docker-compose.prod.yml pull && up -d
# With tunnel: ... --profile tunnel up -d
# Local (build): set -a; . ../.secrets/sepolia.env; set +a; \
# docker compose -f docker-compose.prod.yml up -d --build
#
# Config — a `.env` beside this file (gitignored) or exported shell env:
# SEPOLIA_RPC_URL, PRIVATE_KEY keeper signer (required)
# TUNNEL_TOKEN only with --profile tunnel
# VOLX_REGISTRY (default ghcr.io/obchain), VOLX_TAG (default latest)

name: volx-prod

Expand Down Expand Up @@ -69,6 +77,9 @@ services:
start_period: 5s

ingestion:
# Pre-built image pulled from the registry on the server. `build:` is kept
# for local `up --build`; on the server we only `pull` so the image wins.
image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-ingestion:${VOLX_TAG:-latest}
build:
context: ..
dockerfile: crates/ingestion/Dockerfile
Expand All @@ -89,6 +100,7 @@ services:
disable: true

engine:
image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-engine:${VOLX_TAG:-latest}
build:
context: ..
dockerfile: crates/engine/Dockerfile
Expand All @@ -112,6 +124,7 @@ services:
disable: true

api:
image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-api:${VOLX_TAG:-latest}
build:
context: ..
dockerfile: api/Dockerfile
Expand All @@ -138,6 +151,7 @@ services:
disable: true

keeper:
image: ${VOLX_REGISTRY:-ghcr.io/obchain}/volx-keeper:${VOLX_TAG:-latest}
build:
context: ..
dockerfile: keeper/Dockerfile
Expand All @@ -146,10 +160,12 @@ services:
depends_on:
api:
condition: service_started
env_file:
# SEPOLIA_RPC_URL + PRIVATE_KEY — create on the server, gitignored.
- ../.secrets/sepolia.env
environment:
# Keeper signer secrets come from the compose-dir `.env` (server) or the
# exported shell env (local: `set -a; . ../.secrets/sepolia.env`). Marked
# required so a missing value fails fast instead of starting unsigned.
SEPOLIA_RPC_URL: "${SEPOLIA_RPC_URL:?set SEPOLIA_RPC_URL in .env}"
PRIVATE_KEY: "${PRIVATE_KEY:?set PRIVATE_KEY in .env}"
# Reach the API over the compose network (not the host port).
VOLX_API_URL: "http://api:8080"
# Contract addresses injected directly so the image needs no
Expand All @@ -163,15 +179,15 @@ services:
# Optional containerized Cloudflare tunnel (token-based connector).
# Enable with: --profile tunnel. Create the tunnel + public hostname in the
# Cloudflare Zero Trust dashboard, map the hostname to http://api:8080, and
# put the connector token in ../.secrets/cloudflared.env as TUNNEL_TOKEN.
# provide TUNNEL_TOKEN via the compose-dir `.env`.
cloudflared:
image: cloudflare/cloudflared:latest
container_name: volx-cloudflared
restart: unless-stopped
profiles: ["tunnel"]
command: ["tunnel", "--no-autoupdate", "run"]
env_file:
- ../.secrets/cloudflared.env
environment:
TUNNEL_TOKEN: "${TUNNEL_TOKEN:-}"
depends_on:
api:
condition: service_started
Expand Down
131 changes: 37 additions & 94 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
@@ -1,112 +1,55 @@
#!/usr/bin/env bash
#
# Selective production deploy for VolX.
# Server-side deploy for VolX (registry model).
#
# Rebuilds + restarts ONLY the services whose source changed since the last
# deploy, instead of rebuilding the whole stack every time. Intended to run
# on the always-on server from the repo root:
# Pulls the latest pre-built images from the registry and recreates ONLY the
# services whose image digest changed. No source checkout, no on-host build —
# the server holds just this script, the compose file, clickhouse-init.sql, and
# a `.env` with the keeper secrets.
#
# ./scripts/deploy.sh
# ./deploy.sh # pull + apply
# ./deploy.sh --tunnel # also (re)start the cloudflared tunnel service
#
# State is the commit SHA of the last successful deploy, stored in
# `.last-deployed` (gitignored). On the first run (no state) it does a full
# build + up and records the SHA.
#
# Path -> service mapping:
# api/** -> api
# keeper/** -> keeper
# crates/engine/** -> engine
# crates/ingestion/** -> ingestion
# crates/normalizer|shared-types -> ingestion + engine (shared Rust crates)
# Cargo.toml | Cargo.lock -> ingestion + engine (workspace manifest)
# docker-compose.prod.yml -> recreate full stack (config-level change)
# docker/clickhouse-init.sql -> WARN only (schema loads on cold volume)
#
# Datastores (clickhouse, redis) are official images and are never rebuilt;
# they are only recreated when the compose file itself changes.
# Env (from the `.env` beside the compose file, auto-loaded by compose):
# SEPOLIA_RPC_URL, PRIVATE_KEY keeper signer (required)
# VOLX_REGISTRY (default ghcr.io/obchain), VOLX_TAG (default latest)
# TUNNEL_TOKEN only with --tunnel

set -euo pipefail

cd "$(dirname "$0")/.." # repo root

COMPOSE_FILE="docker/docker-compose.prod.yml"
STATE_FILE=".last-deployed"
DC=(docker compose -f "$COMPOSE_FILE")

OLD_SHA=""
[ -f "$STATE_FILE" ] && OLD_SHA="$(cat "$STATE_FILE")"

echo "==> fetching latest"
git fetch --quiet origin
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if ! git pull --quiet --ff-only origin "$BRANCH"; then
echo "!! git pull --ff-only failed: the server has local/diverging commits on" >&2
echo "!! '$BRANCH'. Resolve manually (git status) then re-run. Nothing deployed." >&2
exit 1
fi
NEW_SHA="$(git rev-parse HEAD)"
cd "$(dirname "$0")"

# First-ever deploy: no state to diff against -> bring up the whole stack.
if [ -z "$OLD_SHA" ]; then
echo "==> no prior deploy state — full build + up"
"${DC[@]}" up -d --build
echo "$NEW_SHA" > "$STATE_FILE"
echo "==> done (initial) @ $NEW_SHA"
exit 0
# Load the local .env so its values are visible to THIS script (compose reads
# it on its own; we need GHCR_TOKEN/GHCR_USER here for the optional login).
if [ -f .env ]; then
set -a; . ./.env; set +a
fi

if [ "$OLD_SHA" = "$NEW_SHA" ]; then
echo "==> already at $NEW_SHA — nothing to deploy"
exit 0
fi

CHANGED="$(git diff --name-only "$OLD_SHA" "$NEW_SHA")"
echo "==> changed files ($OLD_SHA -> $NEW_SHA):"
echo "$CHANGED" | sed 's/^/ /'

match() { echo "$CHANGED" | grep -qE "$1"; }
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
DC=(docker compose -f "$COMPOSE_FILE")

# Compose change is config-level: recreate the full stack and stop here.
if match '^docker/docker-compose\.prod\.yml$'; then
echo "==> compose changed — recreating full stack"
"${DC[@]}" up -d --build
echo "$NEW_SHA" > "$STATE_FILE"
echo "==> done (full) @ $NEW_SHA"
exit 0
fi
PROFILE_ARGS=()
[ "${1:-}" = "--tunnel" ] && PROFILE_ARGS=(--profile tunnel)

# Schema change cannot be applied by a restart (init SQL only runs on a cold
# data volume). Warn loudly; do not touch the data.
if match '^docker/clickhouse-init\.sql$'; then
echo "!! clickhouse-init.sql changed — schema only loads on a COLD volume."
echo "!! NOT applied automatically. Run a manual migration; a full reset"
echo "!! ('docker compose -f $COMPOSE_FILE down -v') DESTROYS all data."
# GHCR auth only needed for PRIVATE packages. Public packages pull anonymously;
# set GHCR_TOKEN (a read:packages PAT) in .env if the images are private.
if [ -n "${GHCR_TOKEN:-}" ]; then
echo "==> docker login ghcr.io as ${GHCR_USER:-obchain}"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "${GHCR_USER:-obchain}" --password-stdin
fi

# Build the set of services to rebuild. Use explicit `if` blocks rather than
# `match ... && SVC[x]=1` so a non-matching `match` can never trip `set -e`.
declare -A SVC
if match '^api/'; then SVC[api]=1; fi
if match '^keeper/'; then SVC[keeper]=1; fi
if match '^crates/engine/'; then SVC[engine]=1; fi
if match '^crates/ingestion/'; then SVC[ingestion]=1; fi
echo "==> pulling images (${VOLX_REGISTRY:-ghcr.io/obchain}, tag ${VOLX_TAG:-latest})"
"${DC[@]}" "${PROFILE_ARGS[@]}" pull

# Shared Rust crates + the workspace manifest/lockfile affect BOTH binaries.
if match '^crates/normalizer/|^crates/shared-types/|^Cargo\.(toml|lock)$'; then
SVC[ingestion]=1
SVC[engine]=1
fi

SERVICES="${!SVC[*]:-}"
if [ -z "$SERVICES" ]; then
echo "==> no service-affecting changes (docs/config/etc) — nothing to rebuild"
echo "$NEW_SHA" > "$STATE_FILE"
exit 0
fi
# `up -d` diffs each service's desired vs running state and recreates only the
# ones whose image (or config) changed — unchanged services are left running.
echo "==> applying"
"${DC[@]}" "${PROFILE_ARGS[@]}" up -d --remove-orphans

echo "==> rebuilding + restarting: $SERVICES"
# shellcheck disable=SC2086
"${DC[@]}" up -d --build $SERVICES
# Drop any now-dangling old image layers to keep the host tidy.
echo "==> pruning dangling images"
docker image prune -f >/dev/null 2>&1 || true

echo "$NEW_SHA" > "$STATE_FILE"
echo "==> done @ $NEW_SHA (services: $SERVICES)"
echo "==> state:"
"${DC[@]}" ps
echo "==> done"
Loading