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
53 changes: 52 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: CI
name: CI/CD

on:
push:
Expand Down Expand Up @@ -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
83 changes: 83 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -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 <host> "bash -s -- <commit-sha>" < 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 <full commit sha>" >&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."