Skip to content

Deploy dev: fb952c506a4d541929be28bb8d401a16a8c62302 #61

Deploy dev: fb952c506a4d541929be28bb8d401a16a8c62302

Deploy dev: fb952c506a4d541929be28bb8d401a16a8c62302 #61

Workflow file for this run

name: Deploy
run-name: >-
${{
github.event_name == 'release' && format('Deploy prod: {0}', github.event.release.tag_name) ||
format('Deploy dev: {0}', github.sha)
}}
concurrency:
group: >-
deploy-${{
github.event_name == 'release' && format('prod-{0}', github.event.release.tag_name) ||
format('dev-{0}', github.ref)
}}
cancel-in-progress: true
on:
push:
branches: [main]
release:
types: [published]
workflow_dispatch:
inputs:
no_cache:
description: Build without Docker cache
required: false
default: false
type: boolean
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set deployment vars
id: vars
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
echo "image_tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
echo "env_file=.env.enc" >> $GITHUB_OUTPUT
echo "stack_name=underlay-prod" >> $GITHUB_OUTPUT
else
echo "image_tag=${{ github.sha }}" >> $GITHUB_OUTPUT
echo "env_file=.env.dev.enc" >> $GITHUB_OUTPUT
echo "stack_name=underlay-dev" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up Node.js and pnpm
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Install and typecheck
run: |
pnpm install --frozen-lockfile
pnpm typecheck
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: production
push: true
provenance: false
sbom: false
no-cache: ${{ inputs.no_cache || false }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.image_tag }}
${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Start SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Install sops
run: |
curl -LO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
sudo mv sops-v3.9.4.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops
- name: Extract DEPLOY_HOST from env file
id: host
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_SECRET_KEY }}
run: |
set -euo pipefail
DEPLOY_HOST=$(sops -d --input-type dotenv --output-type dotenv "${{ steps.vars.outputs.env_file }}" | grep '^DEPLOY_HOST=' | cut -d= -f2)
if [[ -z "$DEPLOY_HOST" ]]; then
echo "::error::DEPLOY_HOST not found in ${{ steps.vars.outputs.env_file }}"
exit 1
fi
echo "deploy_host=${DEPLOY_HOST}" >> $GITHUB_OUTPUT
- name: Add known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "${{ steps.host.outputs.deploy_host }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy over SSH
env:
SSH_USER: ${{ secrets.SSH_USER }}
DEPLOY_HOST: ${{ steps.host.outputs.deploy_host }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
GHCR_USER: ${{ secrets.GHCR_USER }}
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
IMAGE_TAG: ${{ steps.vars.outputs.image_tag }}
ENV_FILE: ${{ steps.vars.outputs.env_file }}
STACK_NAME: ${{ steps.vars.outputs.stack_name }}
run: |
ssh "${SSH_USER}@${DEPLOY_HOST}" \
"env GHCR_USER='${GHCR_USER}' GHCR_TOKEN='${GHCR_TOKEN}' IMAGE_TAG='${IMAGE_TAG}' ENV_FILE='${ENV_FILE}' STACK_NAME='${STACK_NAME}' bash -s -- '${REPO}' '${BRANCH}'" <<'EOS'
set -euo pipefail
REPO="${1:?missing repo}"
BRANCH="${2:-main}"
: "${IMAGE_TAG:?missing IMAGE_TAG}"
: "${GHCR_USER:?missing GHCR_USER}"
: "${GHCR_TOKEN:?missing GHCR_TOKEN}"
: "${STACK_NAME:?missing STACK_NAME}"
REPO_NAME="${REPO##*/}"
APP_DIR="/srv/${STACK_NAME}"
REPO_SSH="git@github.com:${REPO}.git"
ssh-keyscan -H github.com >> ~/.ssh/known_hosts 2>/dev/null
chmod 600 ~/.ssh/known_hosts
if [[ ! -d "${APP_DIR}/.git" ]]; then
sudo mkdir -p "${APP_DIR}"
sudo chown -R "$USER:$USER" "${APP_DIR}"
git clone --branch "${BRANCH}" "${REPO_SSH}" "${APP_DIR}"
fi
cd "${APP_DIR}"
git fetch --prune --tags origin
git checkout --detach "${IMAGE_TAG}"
: "${ENV_FILE:?missing ENV_FILE}"
umask 077
sops -d --input-type dotenv --output-type dotenv "$ENV_FILE" > .env
# Init swarm if not already active
if ! sudo docker info --format '{{.Swarm.LocalNodeState}}' | grep -qx active; then
sudo docker swarm init
fi
echo "$GHCR_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin
sudo docker pull "ghcr.io/${REPO}:${IMAGE_TAG}"
# Deploy/update stack — export .env vars for compose interpolation
# (docker stack deploy does NOT read .env files automatically)
sudo env $(grep -v '^#' .env | grep -v '^$' | xargs) \
IMAGE="ghcr.io/${REPO}" IMAGE_TAG="$IMAGE_TAG" \
docker stack deploy -c docker-compose.yml \
--with-registry-auth --resolve-image always --prune "${STACK_NAME}"
sudo docker stack services "${STACK_NAME}"
# Wait for rollout
wait_rollout() {
local svc="$1" timeout="${2:-300}"
local end=$((SECONDS + timeout))
while (( SECONDS < end )); do
local state
state="$(sudo docker service inspect "$svc" --format '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{end}}' 2>/dev/null || echo "")"
echo " $svc: update_state=$state"
if [[ "$state" == "rollback_started" || "$state" == "rollback_completed" ]]; then
echo " ERROR: $svc rolled back!"
sudo docker service ps "$svc" --no-trunc --format '{{.Name}} {{.CurrentState}} {{.Error}}' | head -10
return 1
fi
if [[ "$state" == "completed" ]] || [[ -z "$state" ]]; then
# Verify all running tasks are healthy (not just started)
local unhealthy
unhealthy="$(sudo docker service ps "$svc" --filter desired-state=running --format '{{.CurrentState}}' 2>/dev/null | grep -cv '^Running' || true)"
if [[ "$unhealthy" == "0" ]]; then
echo " $svc rollout complete"
return 0
fi
fi
sleep 10
done
echo "Rollout timeout for $svc"
sudo docker service ps "$svc" --no-trunc --format '{{.Name}} {{.CurrentState}} {{.Error}}' | head -10
return 1
}
wait_rollout "${STACK_NAME}_app" 300
wait_rollout "${STACK_NAME}_cron" 120
# Cleanup old images
sudo docker image prune -a --filter "until=72h" -f
echo "Deployed ${STACK_NAME} @ ${IMAGE_TAG} to $(hostname)"
EOS