The previous commit's health check worked — "healthy after 4s" was printed — but the step still failed with exit code 1. The bug was mine: I added an "exit 0" in the middle of the remote script, which the original never had. The script is piped to the remote shell's stdin. Exiting in the middle closes the channel while ssh is still writing the remaining lines, and the resulting broken pipe fails the step even though the check passed. The original always ran to the end of the heredoc on success, which is why it never hit this. Now the loop records the result in a flag and breaks, and the only non-zero exit is the very last statement. Both paths simulated: success exits 0, the failure path exits 1 after dumping status and journal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HAH7vVtn7qbcn1EVheecHQ
208 lines
7.2 KiB
YAML
208 lines
7.2 KiB
YAML
# Gitea Actions CI/CD pipeline: lint → test → deploy
|
|
# Triggers on push to main branch.
|
|
# Required variables and secrets (set in Gitea repo settings):
|
|
# Variables:
|
|
# DEPLOY_HOST — server IP or hostname
|
|
# DEPLOY_PATH — absolute path to the directory on the server
|
|
# DEPLOY_USER — SSH username on the server
|
|
# SSH_KNOWN_HOSTS — SSH known_host fingerprint for security
|
|
# SSH_PORT — SSH port of the server
|
|
# Secrets:
|
|
# SSH_PRIVATE_KEY — SSH private key for deployment
|
|
name: Deploy
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
run_setup_db:
|
|
description: "Run deploy/setup_db.sh to configure DB and run migrations"
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
|
|
# Serialize deploys so two quick pushes to main can't rsync/restart on top of
|
|
# each other. Don't cancel an in-flight deploy mid-restart.
|
|
concurrency:
|
|
group: deploy-main
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
lint:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
cache: "pip"
|
|
- run: pip install ruff
|
|
# Whole repo, not just app/: tests/ and scripts/ drifted to 11 findings
|
|
# while unchecked. Rules are pinned in pyproject.toml, so the unpinned
|
|
# ruff above cannot change what this enforces.
|
|
- run: ruff check .
|
|
|
|
test:
|
|
needs: lint
|
|
runs-on: ubuntu-latest
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
env:
|
|
POSTGRES_DB: test_db
|
|
POSTGRES_USER: test_user
|
|
POSTGRES_PASSWORD: test_pass
|
|
options: >-
|
|
--health-cmd pg_isready
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 5
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
cache: "pip"
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "20"
|
|
cache: "npm"
|
|
cache-dependency-path: frontend/package-lock.json
|
|
- run: pip install -e ".[dev]"
|
|
# The Postgres service exists only to validate the migrations against real
|
|
# Postgres (what prod runs). The test suite itself uses an in-memory SQLite
|
|
# engine (tests/conftest.py), so pytest doesn't touch this service.
|
|
- run: alembic upgrade head
|
|
env:
|
|
DATABASE_URL: postgresql+asyncpg://test_user:test_pass@postgres:5432/test_db
|
|
- run: pytest --tb=short
|
|
- run: |
|
|
cd frontend
|
|
npm ci
|
|
if node -e "require.resolve('vitest/package.json')" >/dev/null 2>&1; then
|
|
npm test
|
|
else
|
|
echo "vitest not configured; skipping frontend tests"
|
|
fi
|
|
npm run build
|
|
|
|
deploy:
|
|
needs: test
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
|
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
|
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
SSH_KNOWN_HOSTS: ${{ vars.SSH_KNOWN_HOSTS }}
|
|
SSH_PORT: ${{ vars.SSH_PORT || '22' }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "20"
|
|
cache: "npm"
|
|
cache-dependency-path: frontend/package-lock.json
|
|
|
|
- name: Build frontend
|
|
run: |
|
|
cd frontend
|
|
npm ci
|
|
npm run build
|
|
|
|
- name: Install deploy tools
|
|
run: sudo apt-get update -qq && sudo apt-get install -y -qq rsync openssh-client > /dev/null 2>&1 || true
|
|
|
|
- name: Set up SSH
|
|
run: |
|
|
mkdir -p ~/.ssh
|
|
chmod 700 ~/.ssh
|
|
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
|
chmod 600 ~/.ssh/deploy_key
|
|
echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
|
# known_hosts is supplied, so verify the host key instead of blindly
|
|
# trusting it (StrictHostKeyChecking=no would defeat the fingerprint).
|
|
echo "SSH_OPTS=-i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes -p ${SSH_PORT}" >> "$GITHUB_ENV"
|
|
|
|
- name: Sync files to server
|
|
run: |
|
|
rsync -avz --delete \
|
|
--exclude '.git/' \
|
|
--exclude '.gitea/' \
|
|
--exclude '.env' \
|
|
--exclude '.venv/' \
|
|
--exclude '__pycache__/' \
|
|
--exclude '.pytest_cache/' \
|
|
--exclude 'logs/' \
|
|
--exclude '*.pyc' \
|
|
--exclude 'frontend/node_modules/' \
|
|
-e "ssh $SSH_OPTS" \
|
|
./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
|
|
|
|
- name: Install deps & run migrations
|
|
run: |
|
|
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << REMOTE_SCRIPT
|
|
set -e
|
|
cd ${DEPLOY_PATH}
|
|
|
|
# Create venv if not exists
|
|
if [ ! -d ".venv" ]; then
|
|
python3 -m venv .venv
|
|
fi
|
|
|
|
source .venv/bin/activate
|
|
pip install --quiet --upgrade pip
|
|
pip install --quiet -e .
|
|
|
|
# Setup DB and run migrations
|
|
if [ "${{ inputs.run_setup_db }}" = "true" ] || [ "${{ github.event.inputs.run_setup_db }}" = "true" ]; then
|
|
chmod +x deploy/setup_db.sh
|
|
./deploy/setup_db.sh
|
|
else
|
|
alembic upgrade head
|
|
fi
|
|
REMOTE_SCRIPT
|
|
|
|
- name: Restart service & health check
|
|
run: |
|
|
# Quoted heredoc: the loop's $i / $(seq) must be expanded on the
|
|
# REMOTE host, not by this runner's shell before it is sent.
|
|
ssh $SSH_OPTS "${DEPLOY_USER}@${DEPLOY_HOST}" << 'REMOTE_SCRIPT'
|
|
set -e
|
|
sudo systemctl restart signalplatform.service
|
|
|
|
# Poll instead of guessing how long startup takes. The old fixed
|
|
# "sleep 3" then single curl raced whenever startup ran long, and
|
|
# failed the deploy with a bare "connection refused" on a service
|
|
# that came up healthy a second later.
|
|
#
|
|
# Record the result and fall through rather than exiting early: this
|
|
# script is piped to the remote shell's stdin, so an "exit 0" in the
|
|
# middle can close the channel while ssh is still writing the rest,
|
|
# which surfaces as a broken pipe and fails the step even though the
|
|
# check passed. Only the very last statement may exit non-zero.
|
|
healthy=0
|
|
for i in $(seq 1 60); do
|
|
if curl -fsS http://127.0.0.1:8998/api/v1/health > /dev/null 2>&1; then
|
|
healthy=1
|
|
echo "✓ signalplatform deployed — healthy after ${i}s"
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
# Still down after a minute: a real failure, so leave enough behind
|
|
# to diagnose it without running a second deploy to go looking.
|
|
if [ "$healthy" -ne 1 ]; then
|
|
echo "✗ health check failed: no response on :8998 after 60s"
|
|
sudo systemctl status signalplatform.service --no-pager -l || true
|
|
journalctl -u signalplatform.service -n 80 --no-pager || true
|
|
exit 1
|
|
fi
|
|
REMOTE_SCRIPT
|
|
|
|
- name: Clean up SSH key
|
|
if: always()
|
|
run: rm -f ~/.ssh/deploy_key
|