From 5063791b625db663a06f5e70b6d520e46f59903f Mon Sep 17 00:00:00 2001 From: Aswin Biju Date: Wed, 4 Nov 2026 14:53:28 +0530 Subject: [PATCH] chore: added a Gitea pipeline that deploys to the VPS --- .gitattributes | 1 + .gitea/workflows/deploy.yml | 47 ++++++++++++++++++++ deploy/deploy.sh | 89 +++++++++++++++++++++++++++++++++++++ deploy/install-plugin.sh | 77 ++++++++++++++++++++++++++++++++ ecosystem.config.cjs | 1 + 5 files changed, 215 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/deploy.yml create mode 100644 deploy/deploy.sh create mode 100644 deploy/install-plugin.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..193313f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,47 @@ +name: deploy + +on: + push: + branches: [main] + paths-ignore: + - '**/*.md' + - 'docs/**' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build + - run: python3 -m unittest discover -s hermes-plugin/srs-gate -v + + deploy: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - name: Deploy to the VPS + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + run: | + set -euo pipefail + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key && chmod 600 ~/.ssh/deploy_key + ssh-keygen -y -f ~/.ssh/deploy_key > /dev/null \ + || { echo "ERROR: DEPLOY_SSH_KEY is not a valid private key"; exit 1; } + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts + SSH="ssh -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=15 -o ServerAliveInterval=30 -o ServerAliveCountMax=6 srs@$DEPLOY_HOST" + + # send exactly the commit that was tested, then run its deploy script, all under one lock + git archive --format=tar.gz "$GITHUB_SHA" \ + | $SSH 'flock -w 900 ~/.srs-deploy.lock bash -c "rm -rf ~/srs-api.new && mkdir ~/srs-api.new && tar -xz -C ~/srs-api.new && bash ~/srs-api.new/deploy/deploy.sh"' diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..e5c28be --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,89 @@ +set -euo pipefail + +NEW="$HOME/srs-api.new" +LIVE="$HOME/srs-api" +DATA="$HOME/srs-data" +ENV_FILE="$DATA/.env" +BACKUPS="$DATA/backups" + +fail() { echo "ERROR: $*" >&2; exit 1; } +env_value() { grep -E "^$1=" "$ENV_FILE" | tail -n1 | cut -d= -f2- | tr -d '"' || true; } + +echo "== checks" +[ -d "$NEW" ] || fail "missing $NEW" +[ -f "$ENV_FILE" ] || fail "missing $ENV_FILE: create it once by hand (first deploy)" +chmod 600 "$ENV_FILE" + +DB_PATH="$(env_value DB_PATH)" +case "$DB_PATH" in + "$LIVE"|"$LIVE"/*|"$NEW"|"$NEW"/*) fail "DB_PATH must be outside $LIVE" ;; + /*) ;; + *) fail "DB_PATH must be an absolute path under $DATA (got '${DB_PATH:-unset}')" ;; +esac + +PORT="$(env_value PORT)"; PORT="${PORT:-8080}" + +if ! pm2 describe srs-api >/dev/null 2>&1 && ss -tln | grep -q "127.0.0.1:$PORT "; then + fail "port $PORT is already in use and srs-api is not running: close the SSH tunnel" +fi + +sudo -n -H -u hermes true || fail "srs cannot act as hermes: add the sudoers line (first deploy)" + +echo "== build" +cd "$NEW" +ln -sfn "$ENV_FILE" .env +npm ci +npm run build + +echo "== database backup" +if [ -f "$DB_PATH" ]; then + mkdir -p "$BACKUPS" + sqlite3 "$DB_PATH" ".backup '$BACKUPS/srs-$(date +%Y%m%d-%H%M%S).db'" + ls -1t "$BACKUPS"/srs-*.db | tail -n +15 | xargs -r rm -f +else + echo "no database yet: skipped" +fi + +echo "== migrate" +node dist/db/migrate.js + +echo "== swap and restart" +cd "$HOME" +rm -rf "$LIVE" +mv "$NEW" "$LIVE" +cd "$LIVE" +pm2 startOrRestart ecosystem.config.cjs --update-env +pm2 save + +echo "== health" +ok=0 +for _ in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ok=1; break; fi + sleep 2 +done +[ "$ok" = 1 ] || fail "srs-api did not answer /api/health: check 'pm2 logs srs-api'" +echo "srs-api is healthy" + +echo "== plugin" +SRC="$LIVE/hermes-plugin/srs-gate" +version_of() { sed -n 's/^version:[[:space:]]*"\{0,1\}\([^" ]*\)"\{0,1\}.*/\1/p' | head -n1; } +NEW_VER="$(version_of < "$SRC/plugin.yaml")" +# Runs from "/" because hermes cannot enter srs's folders. +OLD_VER="$(cd / && sudo -n -H -u hermes bash -c 'cat "$HOME/.hermes/plugins/srs-gate/plugin.yaml" 2>/dev/null' | version_of || true)" + +[ -n "$NEW_VER" ] || fail "no version in the repo's plugin.yaml" +if [ -z "$OLD_VER" ]; then + fail "srs-gate is not installed on this server: install and enable it by hand first (HERMES-PLUGIN-PLAN section 6)" +fi +if [ "$NEW_VER" = "$OLD_VER" ]; then echo "plugin $OLD_VER is current: nothing to do"; exit 0; fi +HIGHEST="$(printf '%s\n%s\n' "$OLD_VER" "$NEW_VER" | sort -V | tail -n1)" +if [ "$HIGHEST" != "$NEW_VER" ]; then + echo "WARNING: repo plugin $NEW_VER is older than installed $OLD_VER: plugin skipped (rollback is manual)" + exit 0 +fi + +echo "updating plugin $OLD_VER -> $NEW_VER" +INSTALLER="$(cat "$LIVE/deploy/install-plugin.sh")" +tar -c -C "$SRC" __init__.py plugin.yaml \ + | (cd / && sudo -n -H -u hermes bash -c "$INSTALLER" install-plugin "$OLD_VER" "$NEW_VER") +echo "plugin updated: send a test WhatsApp message and look for 'srs-gate: id=... status=200' in agent.log" diff --git a/deploy/install-plugin.sh b/deploy/install-plugin.sh new file mode 100644 index 0000000..e2d9e9d --- /dev/null +++ b/deploy/install-plugin.sh @@ -0,0 +1,77 @@ +set -euo pipefail + +OLD_VER="${1:?old version}" +NEW_VER="${2:?new version}" + +H="$HOME/.hermes" +PLUGIN="$H/plugins/srs-gate" +BACKUPS="$H/plugin-backups" +KEEP=5 + +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" +export PATH="$HOME/.local/bin:$PATH" + +command -v hermes >/dev/null || { echo "ERROR: hermes command not found in PATH" >&2; exit 1; } +[ -d "$PLUGIN" ] || { echo "ERROR: $PLUGIN does not exist" >&2; exit 1; } + +STAGE="$(mktemp -d "$H/.srs-gate-stage.XXXXXX")" +trap 'rm -rf "$STAGE"' EXIT + +echo "-- unpack" +tar -x -C "$STAGE" -f - +for f in __init__.py plugin.yaml; do + [ -f "$STAGE/$f" ] || { echo "ERROR: missing $f in upload" >&2; exit 1; } +done +[ "$(ls -A "$STAGE" | wc -l)" = 2 ] || { echo "ERROR: unexpected files in upload" >&2; exit 1; } + +echo "-- load check" +PY="$H/hermes-agent/venv/bin/python" +[ -x "$PY" ] || PY=python3 +"$PY" -B - "$STAGE/__init__.py" <<'CHECK' +import asyncio, importlib.util, sys +spec = importlib.util.spec_from_file_location("srs_gate_load_check", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +hooks = [] +class Ctx: + def register_hook(self, name, callback): + hooks.append((name, callback)) +module.register(Ctx()) +assert [name for name, _ in hooks] == ["pre_gateway_dispatch"], "hook not registered" +result = asyncio.run(hooks[0][1](event=None)) +assert result.get("action") == "skip", "hook did not answer skip" +print("plugin loads, registers and skips") +CHECK + +echo "-- backup" +TS="$(date +%Y%m%d-%H%M%S)" +BACKUP="$BACKUPS/srs-gate-$OLD_VER-$TS" +mkdir -p "$BACKUPS" +cp -a "$PLUGIN" "$BACKUP" +ls -1dt "$BACKUPS"/srs-gate-* | tail -n +$((KEEP + 1)) | xargs -r rm -rf + +echo "-- swap" +chmod 755 "$STAGE"; chmod 644 "$STAGE"/* +mv "$PLUGIN" "$H/.srs-gate-replaced" +mv "$STAGE" "$PLUGIN" +rm -rf "$H/.srs-gate-replaced" +trap - EXIT + +echo "-- restart gateway" +hermes -p default gateway restart + +echo "-- check" +ok=0 +for _ in $(seq 1 30); do + sleep 2 + if systemctl --user is-active --quiet hermes-gateway \ + && curl -fsS http://127.0.0.1:3000/health 2>/dev/null | grep -q '"connected"'; then ok=1; break; fi +done +if [ "$ok" != 1 ]; then + echo "ERROR: gateway or bridge not healthy after restart." >&2 + echo "Previous plugin is in: $BACKUP" >&2 + echo "Roll back: mv $PLUGIN $BACKUPS/srs-gate-bad && cp -a $BACKUP $PLUGIN && hermes -p default gateway restart" >&2 + exit 1 +fi +echo "plugin $OLD_VER -> $NEW_VER installed; backup: $BACKUP" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 230a5e6..81535f0 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -7,6 +7,7 @@ module.exports = { exec_mode: 'fork', autorestart: true, max_memory_restart: '300M', + kill_timeout: 12000, env: { NODE_ENV: 'production', },