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
153 changes: 153 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
name: Deploy

# Manual only. Deploys are a decision, not a side effect of merging — but they
# should not depend on one laptop having Docker, the SSH key and a good
# connection. Run this from the Actions tab.
on:
workflow_dispatch:
inputs:
ref:
description: "Branch, tag or SHA to deploy"
required: false
default: main

concurrency:
# Two deploys racing onto one box leaves it in an unknown state.
group: deploy-production
cancel-in-progress: false

env:
IMAGE: ghcr.io/${{ github.repository }}

jobs:
deploy:
name: Build, push and release
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}

- name: Record what is being deployed
id: meta
run: |
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "Deploying $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)"

# Deploy only what passes. Actions does not gate workflow_dispatch on
# other workflows, so the checks are repeated here rather than assumed.
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Test before shipping
run: |
go vet ./...
go test -count=1 ./...

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Tagged by commit as well as latest, so a rollback is a tag away rather
# than a rebuild of an older commit.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
${{ env.IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Authorise SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null

# Pull the exact tag first, point compose at it, then restart. Compose
# never references an image the host does not already have, so a failed
# pull cannot take the site down.
- name: Release
env:
TAG: ${{ steps.meta.outputs.sha }}
run: |
ssh -o BatchMode=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"IMAGE='${{ env.IMAGE }}' TAG='$TAG' bash -s" <<'REMOTE'
set -euo pipefail
cd /opt/hookdrop

echo "→ pulling $IMAGE:$TAG"
docker pull "$IMAGE:$TAG"

cp docker-compose.yml "docker-compose.yml.bak-$(date +%F-%H%M%S)"
PREVIOUS=$(grep -E '^\s*image:' docker-compose.yml | head -1 | sed 's/.*image:[[:space:]]*//')
echo "→ previous image: $PREVIOUS"

# Clean shutdown checkpoints the SQLite WAL before the DB is snapshotted.
docker compose down
cp data/hookdrop.db "data/hookdrop.db.pre-deploy-$(date +%F-%H%M%S)"

sed -i "s|^\(\s*image:\).*|\1 $IMAGE:$TAG|" docker-compose.yml
docker compose up -d

echo "→ waiting for health"
for i in $(seq 1 20); do
if curl -fsS -m 5 http://localhost:8080/health >/dev/null 2>&1; then
echo "✓ healthy on attempt $i"
exit 0
fi
sleep 3
done

echo "✗ unhealthy after 60s — rolling back to $PREVIOUS"
docker compose down
sed -i "s|^\(\s*image:\).*|\1 $PREVIOUS|" docker-compose.yml
docker compose up -d
docker logs hookdrop 2>&1 | tail -30
exit 1
REMOTE

# Confirms the release from outside the box, not just from localhost.
- name: Verify from the public endpoint
run: |
for i in $(seq 1 10); do
body=$(curl -fsS -m 10 https://api.hookdrop.app/health) && {
echo "$body"
up=$(echo "$body" | sed 's/.*"uptime_seconds":\([0-9]*\).*/\1/')
if [ "$up" -lt 300 ]; then
echo "✓ uptime ${up}s — the new build is serving"
exit 0
fi
echo "uptime ${up}s looks stale, retrying"
}
sleep 5
done
echo "✗ public endpoint did not report a freshly started process"
exit 1

- name: Summary
if: always()
run: |
{
echo "### Deploy ${{ job.status }}"
echo ""
echo "- ref: \`${{ inputs.ref }}\`"
echo "- image: \`${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}\`"
echo ""
echo "Roll back by re-running this workflow against the previous commit."
} >> "$GITHUB_STEP_SUMMARY"
65 changes: 47 additions & 18 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
@@ -1,27 +1,56 @@
#!/bin/bash
set -e
# Break-glass deploy, straight from this machine.
#
# The normal route is the Deploy workflow in GitHub Actions (Actions tab →
# Deploy → Run workflow), which builds, pushes to GHCR, releases behind a
# health check and rolls back on failure. Use this only when Actions or GHCR
# is unavailable: it needs Docker locally, cross-compiles to amd64, and ships
# a ~13MB image over your connection.
#
# It also repoints docker-compose.yml at the locally loaded image, because the
# workflow leaves it pointing at a ghcr.io tag.
set -euo pipefail

SERVER="deploy@178.104.166.5"
APP_DIR="/opt/hookdrop"

echo "→ Building Docker image..."
echo "⚠ Break-glass deploy — prefer the Deploy workflow in GitHub Actions."
echo " Working tree: $(git rev-parse --short HEAD)$([ -n "$(git status --porcelain)" ] && echo ' (DIRTY — uncommitted changes will ship)')"
read -r -p " Continue? [y/N] " reply
[ "$reply" = "y" ] || { echo "aborted"; exit 1; }

echo "→ Building image (linux/amd64)..."
docker build --platform linux/amd64 -t hookdrop:latest .

echo "→ Saving image..."
docker save hookdrop:latest | gzip > /tmp/hookdrop.tar.gz
echo "→ Uploading..."
docker save hookdrop:latest | gzip | ssh "$SERVER" 'cat > /tmp/hookdrop.tar.gz'

echo "→ Releasing..."
ssh "$SERVER" 'bash -s' <<'REMOTE'
set -euo pipefail
cd /opt/hookdrop
docker load < /tmp/hookdrop.tar.gz

cp docker-compose.yml "docker-compose.yml.bak-$(date +%F-%H%M%S)"
docker compose down
# Clean shutdown has checkpointed the WAL, so this snapshot is consistent.
cp data/hookdrop.db "data/hookdrop.db.pre-deploy-$(date +%F-%H%M%S)"

# The workflow points this at a ghcr.io tag; send it back to the local image.
sed -i 's|^\(\s*image:\).*|\1 hookdrop:latest|' docker-compose.yml
docker compose up -d

echo "→ Uploading to server..."
scp /tmp/hookdrop.tar.gz $SERVER:/tmp/hookdrop.tar.gz
for i in $(seq 1 20); do
if curl -fsS -m 5 http://localhost:8080/health >/dev/null 2>&1; then
echo "✓ healthy on attempt $i"
rm -f /tmp/hookdrop.tar.gz
exit 0
fi
sleep 3
done

echo "→ Loading and restarting on server..."
ssh $SERVER << 'EOF'
docker load < /tmp/hookdrop.tar.gz
cd /opt/hookdrop
docker compose down
docker compose up -d
docker compose logs --tail=20
rm /tmp/hookdrop.tar.gz
EOF
echo "✗ unhealthy after 60s"
docker logs hookdrop 2>&1 | tail -30
exit 1
REMOTE

rm /tmp/hookdrop.tar.gz
echo "✓ Deployed successfully"
echo "✓ Deployed"
Loading