Compare commits
2 Commits
chore/fix-
...
chore/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b43eb199d | |||
| 480a61b071 |
@@ -6,7 +6,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
force:
|
force:
|
||||||
description: "Force deploy even if gate would skip (1=yes, 0=no)"
|
description: "Force FULL deploy (rebuild+restart) even if gate would hotpatch-only (1=yes, 0=no)"
|
||||||
required: false
|
required: false
|
||||||
default: "0"
|
default: "0"
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ env:
|
|||||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||||
DOCKER_API_VERSION: "1.43"
|
DOCKER_API_VERSION: "1.43"
|
||||||
COMPOSE_VERSION: "2.29.7"
|
COMPOSE_VERSION: "2.29.7"
|
||||||
|
ASTRO_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -92,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
git log -1 --oneline
|
git log -1 --oneline
|
||||||
|
|
||||||
- name: Gate — auto deploy only on annotations/media changes
|
- name: Gate — decide HOTPATCH vs FULL rebuild
|
||||||
env:
|
env:
|
||||||
INPUT_FORCE: ${{ inputs.force }}
|
INPUT_FORCE: ${{ inputs.force }}
|
||||||
run: |
|
run: |
|
||||||
@@ -100,22 +101,29 @@ jobs:
|
|||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
|
|
||||||
FORCE="${INPUT_FORCE:-0}"
|
FORCE="${INPUT_FORCE:-0}"
|
||||||
|
|
||||||
|
# liste fichiers touchés (utile pour copier les médias)
|
||||||
|
CHANGED="$(git show --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
|
||||||
|
printf "%s\n" "$CHANGED" > /tmp/changed.txt
|
||||||
|
|
||||||
|
echo "== changed files =="
|
||||||
|
echo "$CHANGED" | sed -n '1,260p'
|
||||||
|
|
||||||
if [[ "$FORCE" == "1" ]]; then
|
if [[ "$FORCE" == "1" ]]; then
|
||||||
echo "✅ force=1 -> bypass gate -> deploy allowed"
|
echo "GO=1" >> /tmp/deploy.env
|
||||||
echo "GO=1" >> /tmp/deploy.env
|
echo "MODE='full'" >> /tmp/deploy.env
|
||||||
|
echo "✅ force=1 -> MODE=full (rebuild+restart)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# merge commit safe: -m => considère les parents (liste de fichiers plus fiable)
|
# Auto mode: uniquement annotations/media => hotpatch only
|
||||||
CHANGED="$(git show -m --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
|
|
||||||
echo "== changed files =="
|
|
||||||
echo "$CHANGED" | sed -n '1,240p'
|
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
|
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
|
||||||
echo "GO=1" >> /tmp/deploy.env
|
echo "GO=1" >> /tmp/deploy.env
|
||||||
echo "✅ deploy allowed (annotations/media change detected)"
|
echo "MODE='hotpatch'" >> /tmp/deploy.env
|
||||||
|
echo "✅ annotations/media change -> MODE=hotpatch"
|
||||||
else
|
else
|
||||||
echo "GO=0" >> /tmp/deploy.env
|
echo "GO=0" >> /tmp/deploy.env
|
||||||
|
echo "MODE='skip'" >> /tmp/deploy.env
|
||||||
echo "ℹ️ no annotations/media change -> skip deploy"
|
echo "ℹ️ no annotations/media change -> skip deploy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -139,7 +147,7 @@ jobs:
|
|||||||
docker compose version
|
docker compose version
|
||||||
python3 --version
|
python3 --version
|
||||||
|
|
||||||
# 🔥 KEY FIX: reuse existing compose project name if containers already exist
|
# Reuse existing compose project name if containers already exist
|
||||||
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||||
if [[ -z "${PROJ:-}" ]]; then
|
if [[ -z "${PROJ:-}" ]]; then
|
||||||
PROJ="$(docker inspect archicratie-web-green --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
PROJ="$(docker inspect archicratie-web-green --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||||
@@ -148,7 +156,12 @@ jobs:
|
|||||||
echo "COMPOSE_PROJECT_NAME='$PROJ'" >> /tmp/deploy.env
|
echo "COMPOSE_PROJECT_NAME='$PROJ'" >> /tmp/deploy.env
|
||||||
echo "✅ Using COMPOSE_PROJECT_NAME=$PROJ"
|
echo "✅ Using COMPOSE_PROJECT_NAME=$PROJ"
|
||||||
|
|
||||||
- name: Assert required vars (PUBLIC_GITEA_*)
|
# Assert target containers exist (hotpatch needs them)
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
docker inspect "$c" >/dev/null 2>&1 || { echo "❌ missing container $c"; exit 5; }
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Assert required vars (PUBLIC_GITEA_*) — only needed for MODE=full
|
||||||
env:
|
env:
|
||||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||||
@@ -157,24 +170,26 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ hotpatch mode -> vars not required"; exit 0; }
|
||||||
|
|
||||||
test -n "${PUBLIC_GITEA_BASE:-}" || { echo "❌ missing repo var PUBLIC_GITEA_BASE"; exit 2; }
|
test -n "${PUBLIC_GITEA_BASE:-}" || { echo "❌ missing repo var PUBLIC_GITEA_BASE"; exit 2; }
|
||||||
test -n "${PUBLIC_GITEA_OWNER:-}" || { echo "❌ missing repo var PUBLIC_GITEA_OWNER"; exit 2; }
|
test -n "${PUBLIC_GITEA_OWNER:-}" || { echo "❌ missing repo var PUBLIC_GITEA_OWNER"; exit 2; }
|
||||||
test -n "${PUBLIC_GITEA_REPO:-}" || { echo "❌ missing repo var PUBLIC_GITEA_REPO"; exit 2; }
|
test -n "${PUBLIC_GITEA_REPO:-}" || { echo "❌ missing repo var PUBLIC_GITEA_REPO"; exit 2; }
|
||||||
echo "✅ vars OK"
|
echo "✅ vars OK"
|
||||||
|
|
||||||
- name: Assert deploy files exist
|
- name: Assert deploy files exist — only needed for MODE=full
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ hotpatch mode -> files not required"; exit 0; }
|
||||||
|
|
||||||
test -f docker-compose.yml
|
test -f docker-compose.yml
|
||||||
test -f Dockerfile
|
test -f Dockerfile
|
||||||
test -f nginx.conf
|
test -f nginx.conf
|
||||||
echo "✅ deploy files OK"
|
echo "✅ deploy files OK"
|
||||||
|
|
||||||
- name: Build + deploy staging (blue) then smoke
|
- name: FULL — Build + deploy staging (blue) then warmup+smoke
|
||||||
env:
|
env:
|
||||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||||
@@ -183,75 +198,51 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ MODE=$MODE -> skip full rebuild"; exit 0; }
|
||||||
|
|
||||||
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
||||||
|
|
||||||
retry_url() {
|
wait_url() {
|
||||||
local url="$1"; local tries="${2:-45}"; local delay="${3:-1}"
|
local url="$1"
|
||||||
local i=1
|
local label="$2"
|
||||||
while (( i <= tries )); do
|
local tries="${3:-60}"
|
||||||
if curl -fsS --max-time 5 --retry 2 --retry-delay 0 --retry-all-errors "$url" >/dev/null 2>&1; then
|
for i in $(seq 1 "$tries"); do
|
||||||
|
if curl -fsS --max-time 4 "$url" >/dev/null; then
|
||||||
|
echo "✅ $label OK ($url)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "… wait [$i/$tries] $url"
|
echo "… warmup $label ($i/$tries)"
|
||||||
sleep "$delay"
|
sleep 1
|
||||||
((i++))
|
|
||||||
done
|
done
|
||||||
|
echo "❌ timeout $label ($url)"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_html() {
|
|
||||||
local url="$1"; local tries="${2:-45}"; local delay="${3:-1}"
|
|
||||||
local i=1
|
|
||||||
while (( i <= tries )); do
|
|
||||||
local html
|
|
||||||
html="$(curl -fsS --max-time 8 --retry 2 --retry-delay 0 --retry-all-errors "$url" 2>/dev/null || true)"
|
|
||||||
if [[ -n "$html" ]]; then
|
|
||||||
printf "%s" "$html"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "… wait HTML [$i/$tries] $url"
|
|
||||||
sleep "$delay"
|
|
||||||
((i++))
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
dump_container() {
|
|
||||||
local name="$1"
|
|
||||||
echo "== docker ps (filter=$name) =="
|
|
||||||
docker ps -a --filter "name=$name" --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' || true
|
|
||||||
echo "== docker logs (tail) $name =="
|
|
||||||
docker logs --tail 200 "$name" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
echo "TS='$TS'" >> /tmp/deploy.env
|
echo "TS='$TS'" >> /tmp/deploy.env
|
||||||
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
|
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
|
||||||
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
|
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
|
||||||
|
|
||||||
docker compose -p "$PROJ" -f docker-compose.yml config >/dev/null
|
|
||||||
|
|
||||||
docker compose -p "$PROJ" -f docker-compose.yml build web_blue
|
docker compose -p "$PROJ" -f docker-compose.yml build web_blue
|
||||||
|
|
||||||
docker rm -f archicratie-web-blue || true
|
docker rm -f archicratie-web-blue || true
|
||||||
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_blue
|
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_blue
|
||||||
|
|
||||||
retry_url "http://127.0.0.1:8081/para-index.json" 60 1 || { dump_container archicratie-web-blue; exit 31; }
|
# warmup endpoints
|
||||||
retry_url "http://127.0.0.1:8081/annotations-index.json" 60 1 || { dump_container archicratie-web-blue; exit 32; }
|
wait_url "http://127.0.0.1:8081/para-index.json" "blue para-index"
|
||||||
retry_url "http://127.0.0.1:8081/pagefind/pagefind.js" 60 1 || { dump_container archicratie-web-blue; exit 33; }
|
wait_url "http://127.0.0.1:8081/annotations-index.json" "blue annotations-index"
|
||||||
|
wait_url "http://127.0.0.1:8081/pagefind/pagefind.js" "blue pagefind.js"
|
||||||
|
|
||||||
HTML="$(fetch_html "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" 60 1 || true)"
|
CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
||||||
CANON="$(echo "$HTML" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
|
||||||
echo "canonical(blue)=$CANON"
|
echo "canonical(blue)=$CANON"
|
||||||
echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || {
|
echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || {
|
||||||
dump_container archicratie-web-blue
|
|
||||||
echo "❌ staging canonical mismatch"
|
echo "❌ staging canonical mismatch"
|
||||||
exit 34
|
docker logs --tail 120 archicratie-web-blue || true
|
||||||
|
exit 3
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "✅ staging OK"
|
echo "✅ staging OK"
|
||||||
|
|
||||||
- name: Build + deploy live (green) then smoke + rollback if needed
|
- name: FULL — Build + deploy live (green) then warmup+smoke + rollback if needed
|
||||||
env:
|
env:
|
||||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||||
@@ -260,48 +251,27 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ MODE=$MODE -> skip full rebuild"; exit 0; }
|
||||||
|
|
||||||
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
||||||
TS="${TS:-$(date -u +%Y%m%d-%H%M%S)}"
|
TS="${TS:-$(date -u +%Y%m%d-%H%M%S)}"
|
||||||
|
|
||||||
retry_url() {
|
wait_url() {
|
||||||
local url="$1"; local tries="${2:-45}"; local delay="${3:-1}"
|
local url="$1"
|
||||||
local i=1
|
local label="$2"
|
||||||
while (( i <= tries )); do
|
local tries="${3:-60}"
|
||||||
if curl -fsS --max-time 5 --retry 2 --retry-delay 0 --retry-all-errors "$url" >/dev/null 2>&1; then
|
for i in $(seq 1 "$tries"); do
|
||||||
|
if curl -fsS --max-time 4 "$url" >/dev/null; then
|
||||||
|
echo "✅ $label OK ($url)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "… wait [$i/$tries] $url"
|
echo "… warmup $label ($i/$tries)"
|
||||||
sleep "$delay"
|
sleep 1
|
||||||
((i++))
|
|
||||||
done
|
done
|
||||||
|
echo "❌ timeout $label ($url)"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch_html() {
|
|
||||||
local url="$1"; local tries="${2:-45}"; local delay="${3:-1}"
|
|
||||||
local i=1
|
|
||||||
while (( i <= tries )); do
|
|
||||||
local html
|
|
||||||
html="$(curl -fsS --max-time 8 --retry 2 --retry-delay 0 --retry-all-errors "$url" 2>/dev/null || true)"
|
|
||||||
if [[ -n "$html" ]]; then
|
|
||||||
printf "%s" "$html"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "… wait HTML [$i/$tries] $url"
|
|
||||||
sleep "$delay"
|
|
||||||
((i++))
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
dump_container() {
|
|
||||||
local name="$1"
|
|
||||||
echo "== docker ps (filter=$name) =="
|
|
||||||
docker ps -a --filter "name=$name" --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' || true
|
|
||||||
echo "== docker logs (tail) $name =="
|
|
||||||
docker logs --tail 200 "$name" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
rollback() {
|
rollback() {
|
||||||
echo "⚠️ rollback green -> previous image tag (best effort)"
|
echo "⚠️ rollback green -> previous image tag (best effort)"
|
||||||
docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green || true
|
docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green || true
|
||||||
@@ -309,46 +279,40 @@ jobs:
|
|||||||
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green || true
|
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green || true
|
||||||
}
|
}
|
||||||
|
|
||||||
docker compose -p "$PROJ" -f docker-compose.yml config >/dev/null
|
# build/restart green
|
||||||
|
|
||||||
if ! docker compose -p "$PROJ" -f docker-compose.yml build web_green; then
|
if ! docker compose -p "$PROJ" -f docker-compose.yml build web_green; then
|
||||||
dump_container archicratie-web-green
|
echo "❌ build green failed"; rollback; exit 4
|
||||||
rollback
|
|
||||||
exit 40
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker rm -f archicratie-web-green || true
|
docker rm -f archicratie-web-green || true
|
||||||
if ! docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green; then
|
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green
|
||||||
dump_container archicratie-web-green
|
|
||||||
rollback
|
|
||||||
exit 41
|
|
||||||
fi
|
|
||||||
|
|
||||||
retry_url "http://127.0.0.1:8082/para-index.json" 90 1 || { dump_container archicratie-web-green; rollback; exit 42; }
|
# warmup endpoints
|
||||||
retry_url "http://127.0.0.1:8082/annotations-index.json" 90 1 || { dump_container archicratie-web-green; rollback; exit 43; }
|
if ! wait_url "http://127.0.0.1:8082/para-index.json" "green para-index"; then rollback; exit 4; fi
|
||||||
retry_url "http://127.0.0.1:8082/pagefind/pagefind.js" 90 1 || { dump_container archicratie-web-green; rollback; exit 44; }
|
if ! wait_url "http://127.0.0.1:8082/annotations-index.json" "green annotations-index"; then rollback; exit 4; fi
|
||||||
|
if ! wait_url "http://127.0.0.1:8082/pagefind/pagefind.js" "green pagefind.js"; then rollback; exit 4; fi
|
||||||
|
|
||||||
HTML="$(fetch_html "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" 90 1 || true)"
|
CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
||||||
CANON="$(echo "$HTML" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
|
|
||||||
echo "canonical(green)=$CANON"
|
echo "canonical(green)=$CANON"
|
||||||
echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || {
|
echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || {
|
||||||
dump_container archicratie-web-green
|
|
||||||
echo "❌ live canonical mismatch"
|
echo "❌ live canonical mismatch"
|
||||||
|
docker logs --tail 120 archicratie-web-green || true
|
||||||
rollback
|
rollback
|
||||||
exit 45
|
exit 4
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "✅ live OK"
|
echo "✅ live OK"
|
||||||
|
|
||||||
- name: Hotpatch annotations-index.json (deep merge shards) into blue+green
|
- name: HOTPATCH — deep merge shards -> annotations-index + copy changed media into blue+green
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/deploy.env
|
source /tmp/deploy.env
|
||||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||||
|
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import os, re, json, glob, datetime
|
import os, re, json, glob
|
||||||
import yaml
|
import yaml
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
ROOT = os.getcwd()
|
ROOT = os.getcwd()
|
||||||
ANNO_ROOT = os.path.join(ROOT, "src", "annotations")
|
ANNO_ROOT = os.path.join(ROOT, "src", "annotations")
|
||||||
@@ -356,67 +320,70 @@ jobs:
|
|||||||
def is_obj(x): return isinstance(x, dict)
|
def is_obj(x): return isinstance(x, dict)
|
||||||
def is_arr(x): return isinstance(x, list)
|
def is_arr(x): return isinstance(x, list)
|
||||||
|
|
||||||
def key_media(it):
|
def iso_dt(x):
|
||||||
return str((it or {}).get("src",""))
|
if isinstance(x, dt.datetime):
|
||||||
|
if x.tzinfo is None:
|
||||||
|
return x.isoformat()
|
||||||
|
return x.astimezone(dt.timezone.utc).isoformat().replace("+00:00","Z")
|
||||||
|
if isinstance(x, dt.date):
|
||||||
|
return x.isoformat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def normalize(x):
|
||||||
|
s = iso_dt(x)
|
||||||
|
if s is not None: return s
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return {str(k): normalize(v) for k, v in x.items()}
|
||||||
|
if isinstance(x, list):
|
||||||
|
return [normalize(v) for v in x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def key_media(it): return str((it or {}).get("src",""))
|
||||||
def key_ref(it):
|
def key_ref(it):
|
||||||
it = it or {}
|
it = it or {}
|
||||||
return "||".join([
|
return "||".join([str(it.get("url","")), str(it.get("label","")), str(it.get("kind","")), str(it.get("citation",""))])
|
||||||
str(it.get("url","")),
|
def key_comment(it): return str((it or {}).get("text","")).strip()
|
||||||
str(it.get("label","")),
|
|
||||||
str(it.get("kind","")),
|
|
||||||
str(it.get("citation","")),
|
|
||||||
])
|
|
||||||
|
|
||||||
def key_comment(it):
|
|
||||||
return str((it or {}).get("text","")).strip()
|
|
||||||
|
|
||||||
def dedup_extend(dst_list, src_list, key_fn):
|
def dedup_extend(dst_list, src_list, key_fn):
|
||||||
seen = set()
|
seen = set(); out = []
|
||||||
out = []
|
|
||||||
for x in (dst_list or []):
|
for x in (dst_list or []):
|
||||||
k = key_fn(x)
|
x = normalize(x); k = key_fn(x)
|
||||||
if k and k not in seen:
|
if k and k not in seen: seen.add(k); out.append(x)
|
||||||
seen.add(k); out.append(x)
|
|
||||||
for x in (src_list or []):
|
for x in (src_list or []):
|
||||||
k = key_fn(x)
|
x = normalize(x); k = key_fn(x)
|
||||||
if k and k not in seen:
|
if k and k not in seen: seen.add(k); out.append(x)
|
||||||
seen.add(k); out.append(x)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def deep_merge(dst, src):
|
def deep_merge(dst, src):
|
||||||
|
src = normalize(src)
|
||||||
for k, v in (src or {}).items():
|
for k, v in (src or {}).items():
|
||||||
if k in ("media", "refs", "comments_editorial") and is_arr(v):
|
if k in ("media","refs","comments_editorial") and is_arr(v):
|
||||||
if k == "media":
|
if k == "media": dst[k] = dedup_extend(dst.get(k, []), v, key_media)
|
||||||
dst[k] = dedup_extend(dst.get(k, []), v, key_media)
|
elif k == "refs": dst[k] = dedup_extend(dst.get(k, []), v, key_ref)
|
||||||
elif k == "refs":
|
else: dst[k] = dedup_extend(dst.get(k, []), v, key_comment)
|
||||||
dst[k] = dedup_extend(dst.get(k, []), v, key_ref)
|
|
||||||
else:
|
|
||||||
dst[k] = dedup_extend(dst.get(k, []), v, key_comment)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_obj(v):
|
if is_obj(v):
|
||||||
if not is_obj(dst.get(k)):
|
if not is_obj(dst.get(k)): dst[k] = {}
|
||||||
dst[k] = dst.get(k) if is_obj(dst.get(k)) else {}
|
|
||||||
deep_merge(dst[k], v)
|
deep_merge(dst[k], v)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_arr(v):
|
if is_arr(v):
|
||||||
cur = dst.get(k, [])
|
cur = dst.get(k, [])
|
||||||
if not is_arr(cur): cur = []
|
if not is_arr(cur): cur = []
|
||||||
seen = set()
|
seen = set(); out = []
|
||||||
out = []
|
|
||||||
for x in cur:
|
for x in cur:
|
||||||
|
x = normalize(x)
|
||||||
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
||||||
if s not in seen:
|
if s not in seen: seen.add(s); out.append(x)
|
||||||
seen.add(s); out.append(x)
|
|
||||||
for x in v:
|
for x in v:
|
||||||
|
x = normalize(x)
|
||||||
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
||||||
if s not in seen:
|
if s not in seen: seen.add(s); out.append(x)
|
||||||
seen.add(s); out.append(x)
|
|
||||||
dst[k] = out
|
dst[k] = out
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
v = normalize(v)
|
||||||
if k not in dst or dst.get(k) in (None, ""):
|
if k not in dst or dst.get(k) in (None, ""):
|
||||||
dst[k] = v
|
dst[k] = v
|
||||||
|
|
||||||
@@ -429,26 +396,31 @@ jobs:
|
|||||||
arr = entry.get(k)
|
arr = entry.get(k)
|
||||||
if not is_arr(arr): continue
|
if not is_arr(arr): continue
|
||||||
def ts(x):
|
def ts(x):
|
||||||
|
x = normalize(x)
|
||||||
try:
|
try:
|
||||||
return datetime.datetime.fromisoformat(str((x or {}).get("ts","")).replace("Z","+00:00")).timestamp()
|
s = str((x or {}).get("ts",""))
|
||||||
|
return dt.datetime.fromisoformat(s.replace("Z","+00:00")).timestamp() if s else 0
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
|
arr = [normalize(x) for x in arr]
|
||||||
arr.sort(key=lambda x: (ts(x), json.dumps(x, sort_keys=True, ensure_ascii=False)))
|
arr.sort(key=lambda x: (ts(x), json.dumps(x, sort_keys=True, ensure_ascii=False)))
|
||||||
entry[k] = arr
|
entry[k] = arr
|
||||||
|
|
||||||
pages = {}
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
if not os.path.isdir(ANNO_ROOT):
|
if not os.path.isdir(ANNO_ROOT):
|
||||||
raise SystemExit(f"Missing annotations root: {ANNO_ROOT}")
|
raise SystemExit(f"Missing annotations root: {ANNO_ROOT}")
|
||||||
|
|
||||||
|
pages = {}
|
||||||
|
errors = []
|
||||||
|
|
||||||
files = sorted(glob.glob(os.path.join(ANNO_ROOT, "**", "*.yml"), recursive=True))
|
files = sorted(glob.glob(os.path.join(ANNO_ROOT, "**", "*.yml"), recursive=True))
|
||||||
for fp in files:
|
for fp in files:
|
||||||
try:
|
try:
|
||||||
with open(fp, "r", encoding="utf-8") as f:
|
with open(fp, "r", encoding="utf-8") as f:
|
||||||
doc = yaml.safe_load(f) or {}
|
doc = yaml.safe_load(f) or {}
|
||||||
|
doc = normalize(doc)
|
||||||
if not isinstance(doc, dict) or doc.get("schema") != 1:
|
if not isinstance(doc, dict) or doc.get("schema") != 1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
page = str(doc.get("page","")).strip().strip("/")
|
page = str(doc.get("page","")).strip().strip("/")
|
||||||
paras = doc.get("paras") or {}
|
paras = doc.get("paras") or {}
|
||||||
if not page or not isinstance(paras, dict):
|
if not page or not isinstance(paras, dict):
|
||||||
@@ -473,7 +445,7 @@ jobs:
|
|||||||
|
|
||||||
out = {
|
out = {
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"generatedAt": datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat().replace("+00:00","Z"),
|
"generatedAt": dt.datetime.utcnow().replace(tzinfo=dt.timezone.utc).isoformat().replace("+00:00","Z"),
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
"stats": {
|
"stats": {
|
||||||
"pages": len(pages),
|
"pages": len(pages),
|
||||||
@@ -489,14 +461,43 @@ jobs:
|
|||||||
print("OK: wrote /tmp/annotations-index.json pages=", out["stats"]["pages"], "paras=", out["stats"]["paras"], "errors=", out["stats"]["errors"])
|
print("OK: wrote /tmp/annotations-index.json pages=", out["stats"]["pages"], "paras=", out["stats"]["paras"], "errors=", out["stats"]["errors"])
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
# patch JSON into running containers
|
||||||
for c in archicratie-web-blue archicratie-web-green; do
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
echo "== patch $c =="
|
echo "== patch annotations-index.json into $c =="
|
||||||
docker cp /tmp/annotations-index.json "${c}:/usr/share/nginx/html/annotations-index.json"
|
docker cp /tmp/annotations-index.json "${c}:/usr/share/nginx/html/annotations-index.json"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# copy changed media files into containers (so new media appears without rebuild)
|
||||||
|
if [[ -s /tmp/changed.txt ]]; then
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[[ -n "$f" ]] || continue
|
||||||
|
if [[ "$f" == public/media/* ]]; then
|
||||||
|
dest="/usr/share/nginx/html/${f#public/}" # => /usr/share/nginx/html/media/...
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
echo "== copy media into $c: $f -> $dest =="
|
||||||
|
docker exec "$c" sh -lc "mkdir -p \"$(dirname "$dest")\""
|
||||||
|
docker cp "$f" "$c:$dest"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done < /tmp/changed.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# smoke after patch
|
||||||
for p in 8081 8082; do
|
for p in 8081 8082; do
|
||||||
echo "== smoke annotations-index on $p =="
|
echo "== smoke annotations-index on $p =="
|
||||||
curl -fsS "http://127.0.0.1:${p}/annotations-index.json" | python3 -c 'import sys,json; j=json.load(sys.stdin); print("generatedAt:", j.get("generatedAt")); print("pages:", len(j.get("pages") or {}))'
|
curl -fsS --max-time 6 "http://127.0.0.1:${p}/annotations-index.json" \
|
||||||
|
| python3 -c 'import sys,json; j=json.load(sys.stdin); print("generatedAt:", j.get("generatedAt")); print("pages:", len(j.get("pages") or {})); print("paras:", j.get("stats",{}).get("paras"))'
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "✅ hotpatch annotations-index done"
|
echo "✅ hotpatch done"
|
||||||
|
|
||||||
|
- name: Debug on failure (containers status/logs)
|
||||||
|
if: ${{ failure() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "== docker ps =="
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | sed -n '1,80p' || true
|
||||||
|
for c in archicratie-web-blue archicratie-web-green; do
|
||||||
|
echo "== logs $c (tail 200) =="
|
||||||
|
docker logs --tail 200 "$c" || true
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user