Files
signal-platform/.gitea/workflows/deploy.yml
T
dennisthiessenandClaude Opus 5 9846977d00
Deploy / lint (push) Successful in 13s
Deploy / test (push) Successful in 2m26s
Deploy / deploy (push) Successful in 54s
fix(ci): don't exit early inside the ssh heredoc
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
2026-09-07 19:59:12 +02:00

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