185 lines
7.6 KiB
YAML
185 lines
7.6 KiB
YAML
name: deploy
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
|
|
concurrency:
|
|
group: deploy-myapp
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
|
|
container:
|
|
image: docker:27-cli
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
# Same absolute path on both sides. Compose resolves relative bind
|
|
# mounts against its own cwd and hands the result to the HOST daemon;
|
|
# if the paths differ, every ./volume silently points at nothing.
|
|
- /opt/stacks:/opt/stacks
|
|
|
|
# docker:27-cli is Alpine and has no bash. The runner's default shell for
|
|
# `run` is bash, so every step below would die with "bash: not found".
|
|
defaults:
|
|
run:
|
|
shell: sh
|
|
|
|
env:
|
|
STACK_DIR: /opt/stacks/myapp
|
|
|
|
steps:
|
|
- name: Install tools
|
|
run: |
|
|
apk add --no-cache git rsync
|
|
docker compose version >/dev/null 2>&1 || apk add --no-cache docker-cli-compose
|
|
docker compose version
|
|
|
|
- name: Fetch the repo
|
|
run: |
|
|
git clone --depth 1 \
|
|
"http://x-access-token:${{ secrets.GITEA_TOKEN }}@gitea:3000/${{ github.repository }}.git" \
|
|
/src
|
|
echo "Deploying $(git -C /src rev-parse --short HEAD)"
|
|
|
|
- name: Snapshot current stack (for rollback)
|
|
run: |
|
|
rm -rf "${STACK_DIR}.prev"
|
|
if [ -f "${STACK_DIR}/docker-compose.yml" ]; then
|
|
cp -a "$STACK_DIR" "${STACK_DIR}.prev"
|
|
echo "Snapshot taken."
|
|
else
|
|
echo "First deploy - nothing to roll back to."
|
|
fi
|
|
|
|
- name: Sync repo onto the host
|
|
run: |
|
|
mkdir -p "$STACK_DIR"
|
|
# --delete makes the host mirror the repo: files deleted from git are
|
|
# deleted from the host. Everything in --exclude survives that.
|
|
#
|
|
# .env -> your secrets + WG_IP, host-only, must never be wiped
|
|
# data/ -> escape hatch for bind-mounted persistent data
|
|
rsync -a --delete \
|
|
--exclude '.git' \
|
|
--exclude '.gitea' \
|
|
--exclude '.env' \
|
|
--exclude 'data/' \
|
|
/src/ "$STACK_DIR/"
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Fail on a missing WG_IP here, with a message that says so. Without this
|
|
# the next step still catches it -- ${WG_IP} expands to empty, the port
|
|
# loses its host_ip -- but it reports "a published port has no host_ip",
|
|
# which sends you looking for a compose bug that isn't there.
|
|
# ---------------------------------------------------------------------
|
|
- name: Pre-flight - check host .env
|
|
run: |
|
|
cd "$STACK_DIR"
|
|
if [ ! -f .env ]; then
|
|
echo "REFUSING TO DEPLOY: no ${STACK_DIR}/.env"
|
|
echo "It is host-only (never in git) and must define:"
|
|
echo " WG_IP=10.x.x.x # tunnel address to publish on"
|
|
echo " SECRET_KEY=... # puhososik JWT signing key"
|
|
exit 1
|
|
fi
|
|
WG_IP=$(grep -E '^WG_IP=' .env | tail -1 | cut -d= -f2-)
|
|
if [ -z "$WG_IP" ]; then
|
|
echo "REFUSING TO DEPLOY: WG_IP is unset or empty in ${STACK_DIR}/.env"
|
|
exit 1
|
|
fi
|
|
echo "OK - publishing on ${WG_IP}"
|
|
|
|
# ---------------------------------------------------------------------
|
|
# GUARDRAIL 1 - static, runs BEFORE anything is deployed.
|
|
#
|
|
# Docker publishes ports via its own iptables chain, which is evaluated
|
|
# BEFORE ufw's INPUT chain. So `ufw deny 8080` does NOT close a published
|
|
# Docker port. Your firewall says the service is blocked; it is on the
|
|
# public internet. The only reliable defence is to never bind 0.0.0.0.
|
|
#
|
|
# `docker compose config` resolves the file (env vars and all) and emits
|
|
# a host_ip line for every port that has one. If any published port lacks
|
|
# a host_ip, it binds every interface -- so the counts must match.
|
|
# ---------------------------------------------------------------------
|
|
- name: Pre-flight - refuse to publish on all interfaces
|
|
run: |
|
|
cd "$STACK_DIR"
|
|
PUBLISHED=$(docker compose config | grep -c 'published:' || true)
|
|
BOUND=$(docker compose config | grep -c 'host_ip:' || true)
|
|
echo "published ports: $PUBLISHED | explicitly bound: $BOUND"
|
|
if [ "$PUBLISHED" != "$BOUND" ]; then
|
|
echo ""
|
|
echo "REFUSING TO DEPLOY: a published port has no host_ip."
|
|
echo "It would bind 0.0.0.0 and be reachable from the internet,"
|
|
echo "regardless of what ufw says."
|
|
echo "Use \"\${WG_IP}:8080:80\", not \"8080:80\"."
|
|
docker compose config | grep -A4 'published:'
|
|
exit 1
|
|
fi
|
|
echo "OK - every published port is pinned to an interface."
|
|
|
|
- name: Build
|
|
run: |
|
|
cd "$STACK_DIR"
|
|
# Every service here is built from source, so `up -d` alone is not
|
|
# enough: it only builds when the image is MISSING. Once an image
|
|
# exists, a push that changes the code deploys the old binary and
|
|
# the job still goes green. Build explicitly, every time.
|
|
docker compose build
|
|
|
|
- name: Deploy
|
|
run: |
|
|
cd "$STACK_DIR"
|
|
# --ignore-buildable: a plain `pull` also tries to pull the images we
|
|
# build ourselves (myapp-chessstars etc.), which exist in no registry,
|
|
# and fails the deploy. Only the sidecar images get pulled.
|
|
docker compose pull --ignore-buildable
|
|
# --wait blocks until healthchecks pass and exits non-zero if they
|
|
# don't. Without it, `up -d` is green the moment the daemon accepts
|
|
# the command, even if the app dies a second later.
|
|
docker compose up -d --remove-orphans --wait --wait-timeout 120
|
|
|
|
# ---------------------------------------------------------------------
|
|
# GUARDRAIL 2 - runtime. Belt and braces: what actually got bound?
|
|
# ---------------------------------------------------------------------
|
|
- name: Post-deploy - verify nothing landed on a public interface
|
|
run: |
|
|
cd "$STACK_DIR"
|
|
docker compose ps
|
|
if docker compose ps | grep -qE '0\.0\.0\.0:|\[::\]:'; then
|
|
echo ""
|
|
echo "PUBLIC BIND DETECTED after deploy. Rolling back."
|
|
exit 1
|
|
fi
|
|
echo "OK - all published ports are on the tunnel only."
|
|
|
|
- name: Diagnose and roll back
|
|
if: failure()
|
|
run: |
|
|
cd "$STACK_DIR" || exit 1
|
|
echo "======================== STATUS ========================"
|
|
docker compose ps || true
|
|
echo "========================= LOGS ========================="
|
|
docker compose logs --tail=100 --no-color || true
|
|
|
|
if [ -d "${STACK_DIR}.prev" ]; then
|
|
echo "====================== ROLLING BACK ===================="
|
|
rsync -a --delete --exclude '.env' --exclude 'data/' \
|
|
"${STACK_DIR}.prev/" "$STACK_DIR/"
|
|
# The snapshot is source, not images, and the images currently on
|
|
# the host are the new ones we just built over the old tags. Roll
|
|
# the source back without rebuilding and `up` just re-runs the
|
|
# broken build -- the rollback would deploy exactly what failed.
|
|
docker compose build
|
|
docker compose up -d --remove-orphans --wait --wait-timeout 120
|
|
echo "Rolled back to the previous stack."
|
|
else
|
|
echo "No snapshot. Stack left as-is for inspection."
|
|
fi
|
|
|
|
# A successful rollback is still a failed deploy. Keep the red X.
|
|
exit 1
|