diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32b8f5e..9f635ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: CI/CD on: push: @@ -96,3 +96,54 @@ jobs: # Reported for information. A drop in coverage does not fail the build. - name: Coverage report run: coverage report + + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: [lint, test] + timeout-minutes: 10 + + # Pull requests run the checks above, but never deploy. + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + # Two merges in quick succession must never deploy at the same time. + # A later run waits for the running one instead of cancelling it. + concurrency: + group: deploy-production + cancel-in-progress: false + + steps: + - uses: actions/checkout@v7 + + # ssh refuses a private key file that others can read, hence chmod 600. + - name: Set up SSH + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + # The script is never copied to the server: bash reads it from stdin. + # github.sha is exactly the commit the checks above have tested. + - name: Deploy to server (dry run) + run: | + ssh -i ~/.ssh/deploy_key \ + "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \ + "bash -s -- ${{ github.sha }}" < deploy/deploy.sh + + # A 200 alone proves little. The content type shows that the right + # kind of file came back: JSON from Django, CSS from collectstatic. + - name: Verify site + run: | + check() { + result=$(curl -sS -o /dev/null -w '%{http_code} %{content_type}' "$1") + echo "$1 -> $result" + case "$result" in + "200 $2"*) ;; + *) echo "Expected 200 $2" >&2; exit 1 ;; + esac + } + check https://coderr.benjaminblarr.de/api/base-info/ application/json + check https://coderr.benjaminblarr.de/static/admin/css/base.css text/css diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..b76d39c --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Deploys one commit of the Coderr backend. Runs on the server and is fed +# through ssh by the deploy job in .github/workflows/ci.yml: +# ssh "bash -s -- " < deploy/deploy.sh +# +# First version: dry run only. It reports what a deployment would change +# and takes a database backup, but touches neither code nor database. +set -euo pipefail + +APP_DIR=/var/www/coderr/backend +BACKUP_DIR="$HOME/backups/coderr" +KEEP_BACKUPS=10 + +sha="${1:-}" +if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Usage: deploy.sh " >&2 + exit 1 +fi + +# Never add "set -x" to this script: it would print the database password +# into the public log of the workflow run. +env_value() { + local value + value="$(grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\r\"'")" || true + if [ -z "$value" ]; then + echo "$1 is missing in .env" >&2 + exit 1 + fi + printf '%s' "$value" +} + +backup_database() { + local db_name db_user db_password db_host db_port file + db_name="$(env_value DB_NAME)" + db_user="$(env_value DB_USER)" + db_password="$(env_value DB_PASSWORD)" + db_host="$(env_value DB_HOST)" + db_port="$(env_value DB_PORT)" + file="$BACKUP_DIR/$(date +%Y-%m-%d_%H%M%S)_${sha:0:7}.sql.gz" + + mkdir -p "$BACKUP_DIR" + chmod 700 "$HOME/backups" + rm -f "$BACKUP_DIR"/*.partial + PGPASSWORD="$db_password" pg_dump -h "$db_host" -p "$db_port" \ + -U "$db_user" "$db_name" | gzip > "$file.partial" + mv "$file.partial" "$file" + echo "Backup: $file ($(du -h "$file" | cut -f1))" + + ls -1t "$BACKUP_DIR"/*.sql.gz | tail -n +$((KEEP_BACKUPS + 1)) \ + | xargs -r rm -- +} + +cd "$APP_DIR" + +# Tracked files edited by hand on the server would get mixed with the new +# commit. Stop and name them instead. +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "Tracked files were changed on the server:" >&2 + git status --short --untracked-files=no >&2 + exit 1 +fi + +git fetch --quiet origin +git cat-file -e "$sha^{commit}" +echo "Server: $(git log --oneline -1 HEAD)" +echo "Target: $(git log --oneline -1 "$sha")" + +if ! git merge-base --is-ancestor HEAD "$sha"; then + echo "Target does not build on the server's commit." >&2 + exit 1 +fi + +echo "--- Changed files ---" +git diff --stat HEAD "$sha" +echo "--- New migration files ---" +git diff --name-only --diff-filter=A HEAD "$sha" -- '*/migrations/*.py' +echo "--- Changes to requirements.txt ---" +git diff HEAD "$sha" -- requirements.txt +echo "--- Unapplied migrations of the running code ---" +.venv/bin/python manage.py migrate --plan + +backup_database +echo "Dry run finished, nothing was deployed."