449e4d2df3
jecreativ.ro's first production deploy went red for behaving exactly as configured: it runs in UnderConstruction mode, so `/` correctly answers 302 to the placeholder, and the check asserted a bare 200. Now `-L` follows the redirect and the FINAL code is asserted. A redirect means the app is up and routing, which is what this step is for; a real failure (502, 500, refused) still reports. The version gate itself was right throughout -- it read 1bafc14 from the production host, including through the under-construction middleware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpTYCBLH58XzrM7n3xPJ5N
186 lines
7.4 KiB
YAML
186 lines
7.4 KiB
YAML
name: Build and Push Docker Images
|
|
|
|
# Branch-driven deploys — no yaml edits to switch environment:
|
|
# merge into `staging` -> tag :staging (staging Watchtower deploys)
|
|
# merge into `production` -> tag :production (production Watchtower deploys)
|
|
# `main` is the day-to-day work branch and deploys nothing.
|
|
on:
|
|
push:
|
|
branches:
|
|
- staging
|
|
- production
|
|
|
|
env:
|
|
GIT_HOST: docker-git.easysoft.ro
|
|
REGISTRY_HOST: registry.easysoft.ro
|
|
DOCKER_BUILDKIT: "1"
|
|
API_IMAGE: apps/myai-api
|
|
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
|
|
RAG_API_IMAGE: apps/myai-rag-api
|
|
EMAIL_API_IMAGE: apps/myai-email-api
|
|
WEB_IMAGE: apps/myai-web
|
|
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
|
|
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
|
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
|
|
IMAGE_TAG: ${{ github.ref_name }} # branch name == image tag (staging | production)
|
|
WEB_PORT: "5140" # host port the web container is published on
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: host
|
|
|
|
steps:
|
|
- name: Checkout the pushed commit
|
|
env:
|
|
TOKEN: ${{ secrets.REPO_TOKEN }}
|
|
run: |
|
|
git clone "http://gelu:${TOKEN}@${GIT_HOST}:3000/${GITHUB_REPOSITORY}.git" .
|
|
git checkout "${{ github.sha }}"
|
|
|
|
- name: Login to registry
|
|
run: |
|
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY_HOST}" \
|
|
-u "${{ secrets.REGISTRY_USER }}" \
|
|
--password-stdin
|
|
|
|
- name: Build API image
|
|
run: |
|
|
docker build -f Apis/api/Dockerfile -t "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build CV Matcher API image
|
|
run: |
|
|
docker build -f Apis/cv-matcher-api/Dockerfile -t "${REGISTRY_HOST}/${CV_MATCHER_API_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build RAG API image
|
|
run: |
|
|
docker build -f Apis/rag-api/Dockerfile -t "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build Email API image
|
|
run: |
|
|
docker build -f Apis/email-api/Dockerfile -t "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build Web image
|
|
run: |
|
|
docker build --build-arg GIT_SHA="${{ github.sha }}" \
|
|
-f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build CV cleanup job image
|
|
run: |
|
|
docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build CV search job image
|
|
run: |
|
|
docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Build Page Fetcher API image
|
|
run: |
|
|
docker build -f Apis/page-fetcher-api/Dockerfile -t "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" .
|
|
|
|
- name: Push API image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push CV Matcher API image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${CV_MATCHER_API_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push RAG API image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push Email API image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push Web image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push CV cleanup job image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push CV search job image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Push Page Fetcher API image
|
|
run: |
|
|
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
|
|
|
|
- name: Reclaim disk space (keep recent build cache)
|
|
if: always()
|
|
run: |
|
|
docker image prune -f # dangling only (keep base images)
|
|
# Building and pushing an image proves nothing about what the host is running.
|
|
# Watchtower pulls asynchronously, and for a month it was pulling a tag nobody
|
|
# intended -- with every run green, because no step ever asked the deployed site
|
|
# what it was serving. This job asks.
|
|
#
|
|
# It polls the deploy host directly on the LAN rather than the public hostname:
|
|
# the runner sits inside the network, only easysoft.ro has a staging equivalent in
|
|
# public DNS, and going direct also takes Caddy and any CDN out of the answer.
|
|
smoke:
|
|
runs-on: host
|
|
needs: build
|
|
steps:
|
|
- name: Wait for the deploy host to serve this commit
|
|
run: |
|
|
case "${{ github.ref_name }}" in
|
|
staging) HOST=192.168.1.111 ;;
|
|
production) HOST=192.168.1.101 ;;
|
|
*) echo "::error::No deploy host mapped for '${{ github.ref_name }}'."; exit 1 ;;
|
|
esac
|
|
|
|
URL="http://${HOST}:${WEB_PORT}/version.json"
|
|
echo "Polling ${URL} for ${{ github.sha }}"
|
|
|
|
# 10 minutes: Watchtower's poke is fire-and-forget with a 30s fallback poll,
|
|
# and the container still has to start.
|
|
# ⚠️ Steps run under `bash -e -o pipefail`, so a polling loop has to be written
|
|
# defensively: the FIRST miss is the normal case, not an error.
|
|
# - `curl -sf | sed` fails the whole pipeline under pipefail while the old
|
|
# container is still up (404/connection refused), so `|| GOT=""` is required
|
|
# - `[ test ] && { ... }` returns non-zero when the test fails, which under -e
|
|
# aborts the step. Use `if`.
|
|
# Getting both wrong made the first run of this job fail in 20 seconds.
|
|
DEADLINE=$(( $(date +%s) + 600 ))
|
|
while :; do
|
|
GOT=$(curl -sf -m 15 "${URL}" 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') || GOT=""
|
|
|
|
if [ "${GOT}" = "${{ github.sha }}" ]; then
|
|
echo "Serving ${GOT}."
|
|
break
|
|
fi
|
|
|
|
if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
|
|
echo "::error::Timed out after 10m. ${HOST} is serving '${GOT:-nothing}', wanted ${{ github.sha }}."
|
|
echo "Either Watchtower never pulled the new image, the container failed to"
|
|
echo "start, or the stack's IMAGE_TAG does not match this branch."
|
|
exit 1
|
|
fi
|
|
echo " still serving '${GOT:-nothing}' ..."
|
|
sleep 15
|
|
done
|
|
|
|
- name: Check the site actually answers
|
|
run: |
|
|
case "${{ github.ref_name }}" in
|
|
staging) HOST=192.168.1.111 ;;
|
|
production) HOST=192.168.1.101 ;;
|
|
esac
|
|
# `-L` follows redirects and we assert on the FINAL code, because a 302 from `/`
|
|
# is a healthy answer for a site running in UnderConstruction mode -- it means the
|
|
# app is up and routing. Asserting a bare 200 failed jecreativ.ro's first
|
|
# production deploy for doing exactly what it was configured to do.
|
|
#
|
|
# `|| CODE=000` for the same reason as above: curl exiting non-zero on a
|
|
# connection failure must produce a reportable code, not kill the step before
|
|
# it can say what went wrong. (`-s` without `-f` already tolerates 4xx/5xx.)
|
|
CODE=$(curl -sL -o /dev/null -w '%{http_code}' -m 20 "http://${HOST}:${WEB_PORT}/") || CODE=000
|
|
if [ "${CODE}" != "200" ]; then
|
|
echo "::error::Home page returned ${CODE}."
|
|
exit 1
|
|
fi
|
|
echo "Home page 200."
|