Files
ad_fun/.gitea/workflows/deploy.yaml
T
you ea69347829
deploy / deploy (push) Has been cancelled
initial deploy stack
2026-07-15 14:01:59 +03:00

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