diff --git a/.gitea/workflows/deploy-staging-live.yml b/.gitea/workflows/deploy-staging-live.yml new file mode 100644 index 0000000..24ec8ce --- /dev/null +++ b/.gitea/workflows/deploy-staging-live.yml @@ -0,0 +1,223 @@ +name: Deploy (staging + live) — annotations + +on: + push: + branches: [main] + workflow_dispatch: + +env: + # valeurs "publiques" injectées au build (import.meta.env.PUBLIC_*) + PUBLIC_GITEA_BASE: https://gitea.archicratie.trans-hands.synology.me + PUBLIC_GITEA_OWNER: Archicratia + PUBLIC_GITEA_REPO: archicratie-edition + + # canonical/sitemap (IMPORTANT) + STAGING_PUBLIC_SITE: https://staging.archicratie.trans-hands.synology.me + LIVE_PUBLIC_SITE: https://archicratie.trans-hands.synology.me + + REQUIRE_PUBLIC_SITE: "1" + +defaults: + run: + shell: bash + +jobs: + deploy: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm + + steps: + - name: Tools sanity + install docker cli + run: | + set -euo pipefail + git --version + node --version + npm --version + + # docker cli + compose plugin + curl (dans le conteneur du job) + apt-get update -y + apt-get install -y --no-install-recommends docker.io docker-compose-plugin curl ca-certificates + docker --version + docker compose version + curl --version | head -n 1 + + - name: Checkout (from event.json, no external actions) + run: | + set -euo pipefail + export EVENT_JSON="/var/run/act/workflow/event.json" + test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; } + + eval "$(node --input-type=module -e 'import fs from "node:fs"; + const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON,"utf8")); + const repo = + ev?.repository?.clone_url || + (ev?.repository?.html_url ? (ev.repository.html_url.replace(/\/$/,"") + ".git") : ""); + const after = + ev?.after || + ev?.pull_request?.head?.sha || + ev?.head_commit?.id || + ev?.sha || ""; + const before = + ev?.before || ""; + if (!repo) throw new Error("No repository url in event.json"); + if (!after) throw new Error("No after sha in event.json"); + process.stdout.write(`REPO_URL=${JSON.stringify(repo)}\nAFTER=${JSON.stringify(after)}\nBEFORE=${JSON.stringify(before)}\n`); + ')" + + echo "Repo URL: $REPO_URL" + echo "BEFORE: $BEFORE" + echo "AFTER: $AFTER" + + rm -rf .git + git init -q + git remote add origin "$REPO_URL" + + # fetch AFTER (+ BEFORE si dispo) + git fetch --depth 50 origin "$AFTER" + git -c advice.detachedHead=false checkout -q FETCH_HEAD + + # fetch BEFORE si c'est un sha plausible (pas 0000..) + if [[ -n "$BEFORE" && ! "$BEFORE" =~ ^0+$ ]]; then + git fetch --depth 50 origin "$BEFORE" || true + fi + + git log -1 --oneline + + - name: Gate — auto deploy only on annotations/media changes + run: | + set -euo pipefail + + # récupère BEFORE/AFTER depuis event.json (déjà dispo dans env du shell précédent? non) + export EVENT_JSON="/var/run/act/workflow/event.json" + BEFORE="$(node --input-type=module -e 'import fs from "node:fs"; const ev=JSON.parse(fs.readFileSync(process.env.EVENT_JSON,"utf8")); process.stdout.write(String(ev?.before||""))')" + AFTER="$(git rev-parse HEAD)" + + echo "AFTER=$AFTER" + echo "BEFORE=$BEFORE" + + # si BEFORE absent/zeros → on autorise (premier push / edge case) + if [[ -z "$BEFORE" || "$BEFORE" =~ ^0+$ ]]; then + echo "ℹ️ No BEFORE sha => allow deploy" + exit 0 + fi + + # liste des fichiers changés + CHANGED="$(git diff --name-only "$BEFORE" "$AFTER" || true)" + echo "== changed files ==" + echo "$CHANGED" | sed -n '1,240p' + + # autorisé uniquement sur ces chemins + BAD=0 + while IFS= read -r f; do + [[ -z "$f" ]] && continue + if [[ "$f" =~ ^src/annotations/ ]] || [[ "$f" =~ ^public/media/ ]]; then + continue + fi + # tout le reste => on skip l’auto-deploy (pas un échec) + echo "⚠️ non-annotation change detected: $f" + BAD=1 + done <<< "$CHANGED" + + if [[ "$BAD" -eq 1 ]]; then + echo "ℹ️ Skip auto deploy (changes not limited to annotations/media)." + echo "SKIP_DEPLOY=1" >> "$GITHUB_ENV" 2>/dev/null || true + echo "SKIP_DEPLOY=1" >> /tmp/deploy.env + exit 0 + fi + + echo "✅ Allowed: annotations/media only" + + - name: Build + deploy staging (blue) then smoke + run: | + set -euo pipefail + if [[ -f /tmp/deploy.env ]] && grep -q '^SKIP_DEPLOY=1' /tmp/deploy.env; then + echo "ℹ️ skipped" + exit 0 + fi + + TS="$(date -u +%Y%m%d-%H%M%S)" + echo "TS=$TS" + + # backup image tag (best effort) + docker image inspect archicratie-web:blue >/dev/null 2>&1 && \ + docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true + + # IMPORTANT: forcer les args de build (staging) + export PUBLIC_SITE="$STAGING_PUBLIC_SITE" + + # on évite les conflits de "container name already in use" + docker rm -f archicratie-web-blue >/dev/null 2>&1 || true + + docker compose -f docker-compose.yml build web_blue + docker compose -f docker-compose.yml up -d --force-recreate web_blue + + # smoke staging (8081) + echo "== smoke staging (8081) ==" + curl -fsS -o /dev/null "http://127.0.0.1:8081/para-index.json" + curl -fsS -o /dev/null "http://127.0.0.1:8081/annotations-index.json" + CANON="$(curl -fsS "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)" + echo "canonical: $CANON" + echo "$CANON" | grep -q "$STAGING_PUBLIC_SITE" || { echo "❌ staging canonical mismatch"; exit 1; } + echo "✅ staging OK" + + - name: Build + deploy live (green) then smoke + rollback if needed + run: | + set -euo pipefail + if [[ -f /tmp/deploy.env ]] && grep -q '^SKIP_DEPLOY=1' /tmp/deploy.env; then + echo "ℹ️ skipped" + exit 0 + fi + + TS="$(date -u +%Y%m%d-%H%M%S)" + echo "TS=$TS" + + # backup image tag (best effort) + docker image inspect archicratie-web:green >/dev/null 2>&1 && \ + docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true + + # IMPORTANT: args de build (live) + export PUBLIC_SITE="$LIVE_PUBLIC_SITE" + + # on évite les conflits + docker rm -f archicratie-web-green >/dev/null 2>&1 || true + + # build + up + set +e + docker compose -f docker-compose.yml build web_green + BUILD_RC=$? + set -e + [[ "$BUILD_RC" -eq 0 ]] || { echo "❌ build live failed"; exit 1; } + + docker compose -f docker-compose.yml up -d --force-recreate web_green + + # smoke live (8082) + echo "== smoke live (8082) ==" + set +e + curl -fsS -o /dev/null "http://127.0.0.1:8082/para-index.json" + A1=$? + curl -fsS -o /dev/null "http://127.0.0.1:8082/annotations-index.json" + A2=$? + CANON="$(curl -fsS "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)" + echo "canonical: $CANON" + echo "$CANON" | grep -q "$LIVE_PUBLIC_SITE" + A3=$? + set -e + + if [[ "$A1" -ne 0 || "$A2" -ne 0 || "$A3" -ne 0 ]]; then + echo "❌ live smoke failed => rollback" + + # rollback image tag if backup exists + if docker image inspect "archicratie-web:green.BAK.${TS}" >/dev/null 2>&1; then + docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green + docker rm -f archicratie-web-green >/dev/null 2>&1 || true + docker compose -f docker-compose.yml up -d --force-recreate --no-build web_green + echo "✅ rollback applied" + else + echo "⚠️ no backup image tag found => cannot rollback automatically" + fi + + exit 1 + fi + + echo "✅ live OK" \ No newline at end of file