@@ -0,0 +1,184 @@
|
||||
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
|
||||
Reference in New Issue
Block a user