11name : Deploy
22
3+ run-name : >-
4+ ${{
5+ github.event_name == 'release' && format('Deploy prod: {0}', github.event.release.tag_name) ||
6+ format('Deploy dev: {0}', github.sha)
7+ }}
8+
39concurrency :
4- group : deploy-${{ github.ref }}
10+ group : >-
11+ deploy-${{
12+ github.event_name == 'release' && format('prod-{0}', github.event.release.tag_name) ||
13+ format('dev-{0}', github.ref)
14+ }}
515 cancel-in-progress : true
616
717on :
818 push :
9- branches : [ main ]
19+ branches : [main]
20+ release :
21+ types : [published]
1022 workflow_dispatch :
23+ inputs :
24+ no_cache :
25+ description : Build without Docker cache
26+ required : false
27+ default : false
28+ type : boolean
1129
1230env :
1331 REGISTRY : ghcr.io
@@ -18,45 +36,61 @@ permissions:
1836 packages : write
1937
2038jobs :
21- build :
22- name : Build & Push Image
39+ deploy :
40+ name : Deploy
2341 runs-on : ubuntu-latest
24- outputs :
25- image_tag : ${{ steps.meta.outputs.version }}
42+
2643 steps :
27- - uses : actions/checkout@v4
44+ - name : Checkout
45+ uses : actions/checkout@v4
46+
47+ - name : Set deployment vars
48+ id : vars
49+ run : |
50+ if [[ "${{ github.event_name }}" == "release" ]]; then
51+ echo "image_tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
52+ echo "host=${{ secrets.SSH_HOST_PROD }}" >> $GITHUB_OUTPUT
53+ echo "env_file=.env.enc" >> $GITHUB_OUTPUT
54+ echo "stack_name=underlay" >> $GITHUB_OUTPUT
55+ else
56+ echo "image_tag=${{ github.sha }}" >> $GITHUB_OUTPUT
57+ echo "host=${{ secrets.SSH_HOST_DEV }}" >> $GITHUB_OUTPUT
58+ echo "env_file=.env.dev.enc" >> $GITHUB_OUTPUT
59+ echo "stack_name=underlay-dev" >> $GITHUB_OUTPUT
60+ fi
2861
29- - uses : docker/setup-buildx-action@v3
62+ - name : Set up Docker Buildx
63+ uses : docker/setup-buildx-action@v3
3064
31- - uses : docker/login-action@v3
65+ - name : Log in to GHCR
66+ uses : docker/login-action@v3
3267 with :
3368 registry : ${{ env.REGISTRY }}
3469 username : ${{ github.actor }}
3570 password : ${{ secrets.GITHUB_TOKEN }}
3671
37- - id : meta
72+ - name : Extract metadata
73+ id : meta
3874 uses : docker/metadata-action@v5
3975 with :
4076 images : ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
41- tags : |
42- type=sha,prefix=
43- type=raw,value=latest,enable={{is_default_branch}}
4477
45- - uses : docker/build-push-action@v5
78+ - name : Build and push
79+ uses : docker/build-push-action@v6
4680 with :
4781 context : .
4882 target : production
4983 push : true
50- tags : ${{ steps.meta.outputs.tags }}
51- labels : ${{ steps.meta.outputs.labels }}
84+ provenance : false
85+ sbom : false
86+ no-cache : ${{ inputs.no_cache || false }}
5287 cache-from : type=gha
5388 cache-to : type=gha,mode=max
89+ tags : |
90+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.image_tag }}
91+ ${{ steps.meta.outputs.tags }}
92+ labels : ${{ steps.meta.outputs.labels }}
5493
55- deploy :
56- name : Deploy
57- needs : build
58- runs-on : ubuntu-latest
59- steps :
6094 - name : Start SSH agent
6195 uses : webfactory/ssh-agent@v0.9.0
6296 with :
@@ -65,34 +99,34 @@ jobs:
6599 - name : Add known hosts
66100 run : |
67101 mkdir -p ~/.ssh
68- ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
102+ ssh-keyscan -H "${{ steps.vars.outputs.host }}" >> ~/.ssh/known_hosts
69103
70104 - name : Deploy over SSH
71105 env :
72106 SSH_USER : ${{ secrets.SSH_USER }}
73- SSH_HOST : ${{ secrets.SSH_HOST }}
107+ SSH_HOST : ${{ steps.vars.outputs.host }}
74108 REPO : ${{ github.repository }}
75109 BRANCH : ${{ github.ref_name }}
76110 GHCR_USER : ${{ secrets.GHCR_USER }}
77111 GHCR_TOKEN : ${{ secrets.GHCR_TOKEN }}
78- IMAGE : ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
79- IMAGE_TAG : ${{ needs.build.outputs.image_tag }}
80- ENV_FILE : ${{ vars.ENV_FILE || '.env.enc' }}
81- DEPLOY_PATH : ${{ vars.DEPLOY_PATH || '/opt/underlay' }}
112+ IMAGE_TAG : ${{ steps.vars.outputs.image_tag }}
113+ ENV_FILE : ${{ steps.vars.outputs.env_file }}
114+ STACK_NAME : ${{ steps.vars.outputs.stack_name }}
82115 run : |
83116 ssh "${SSH_USER}@${SSH_HOST}" \
84- "env GHCR_USER='${GHCR_USER}' GHCR_TOKEN='${GHCR_TOKEN}' IMAGE ='${IMAGE }' IMAGE_TAG ='${IMAGE_TAG }' ENV_FILE ='${ENV_FILE }' bash -s -- '${REPO}' '${BRANCH}' '${DEPLOY_PATH }'" <<'EOS'
117+ "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'
85118 set -euo pipefail
86119
87120 REPO="${1:?missing repo}"
88121 BRANCH="${2:-main}"
89- APP_DIR="${3:-/opt/underlay}"
90122
91- : "${IMAGE:?missing IMAGE}"
92123 : "${IMAGE_TAG:?missing IMAGE_TAG}"
93124 : "${GHCR_USER:?missing GHCR_USER}"
94125 : "${GHCR_TOKEN:?missing GHCR_TOKEN}"
126+ : "${STACK_NAME:?missing STACK_NAME}"
95127
128+ REPO_NAME="${REPO##*/}"
129+ APP_DIR="/srv/${REPO_NAME}"
96130 REPO_SSH="git@github.com:${REPO}.git"
97131
98132 ssh-keyscan -H github.com >> ~/.ssh/known_hosts 2>/dev/null
@@ -105,38 +139,54 @@ jobs:
105139 fi
106140
107141 cd "${APP_DIR}"
108- git fetch --prune origin
109- git checkout "${BRANCH}"
110- git pull origin "${BRANCH}"
142+ git fetch --prune --tags origin
143+ git checkout --detach "${IMAGE_TAG}"
111144
112145 : "${ENV_FILE:?missing ENV_FILE}"
146+ umask 077
113147 sops -d --input-type dotenv --output-type dotenv "$ENV_FILE" > .env
114148
115- echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
149+ # Init swarm if not already active
150+ if ! sudo docker info --format '{{.Swarm.LocalNodeState}}' | grep -qx active; then
151+ sudo docker swarm init
152+ fi
153+
154+ echo "$GHCR_TOKEN" | sudo docker login ghcr.io -u "$GHCR_USER" --password-stdin
155+
156+ sudo docker pull "ghcr.io/${REPO}:${IMAGE_TAG}"
116157
117- export IMAGE IMAGE_TAG
118- docker compose pull
119- docker compose up -d --remove-orphans
158+ # Deploy/update stack
159+ sudo env IMAGE="ghcr.io/${REPO}" IMAGE_TAG="$IMAGE_TAG" \
160+ docker stack deploy -c docker-compose.yml \
161+ --with-registry-auth --resolve-image always --prune "${STACK_NAME}"
120162
121- wait_healthy() {
122- local timeout="${1:-120}"
163+ sudo docker stack services "${STACK_NAME}"
164+
165+ # Wait for rollout
166+ wait_rollout() {
167+ local svc="$1" timeout="${2:-300}"
123168 local end=$((SECONDS + timeout))
124169 while (( SECONDS < end )); do
125- if ! docker compose ps | grep -qiE 'starting|unhealthy|restarting'; then
126- echo "All services running"
127- docker compose ps
170+ local desired running state
171+ desired="$(sudo docker service inspect "$svc" --format '{{.Spec.Mode.Replicated.Replicas}}' 2>/dev/null || echo "")"
172+ running="$(sudo docker service ps "$svc" --filter desired-state=running --format '{{.CurrentState}}' 2>/dev/null | grep -c '^Running' || true)"
173+ state="$(sudo docker service inspect "$svc" --format '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{end}}' 2>/dev/null || echo "")"
174+ echo " $svc: desired=$desired running=$running state=$state"
175+ if [[ -n "$desired" && "$running" == "$desired" ]] && { [[ -z "$state" ]] || [[ "$state" == "completed" ]]; }; then
176+ echo " $svc rollout complete"
128177 return 0
129178 fi
130- echo "Waiting for services..."
131179 sleep 5
132180 done
133- echo "Rollout timeout"
134- docker compose ps
181+ echo "Rollout timeout for $svc"
135182 return 1
136183 }
137- wait_healthy 120
138184
139- docker image prune -a --filter "until=72h" -f
185+ wait_rollout "${STACK_NAME}_app" 300
186+ wait_rollout "${STACK_NAME}_cron" 120
187+
188+ # Cleanup old images
189+ sudo docker image prune -a --filter "until=72h" -f
140190
141- echo "Deployed to $(hostname)"
191+ echo "Deployed ${STACK_NAME} @ ${IMAGE_TAG} to $(hostname)"
142192 EOS
0 commit comments