Deploy dev: fb952c506a4d541929be28bb8d401a16a8c62302 #61
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |