#!/usr/bin/env bash # Release Fase 1+2, issue #181: 68 traducciones publicadas + 16 MP3 TTS. # Por defecto es sólo dry-run. --apply requiere confirmación explícita de Rafa. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" MODE="dry-run" if [[ "${1:-}" == "--apply" ]]; then MODE="apply" elif [[ "${1:-}" == "--verify" ]]; then MODE="verify" elif [[ "${1:-}" != "" && "${1:-}" != "--dry-run" ]]; then echo "Uso: $0 [--dry-run|--verify|--apply]" >&2 exit 2 fi # shellcheck disable=SC1091 source ~/.hermes/profiles/feadulta/.env : "${FEA_PROD_WPLOAD:=/web/wp-load.php}" if [[ "$FEA_PROD_WPLOAD" != "/web/wp-load.php" ]]; then echo "ABORT: FEA_PROD_WPLOAD debe ser /web/wp-load.php (recibido: $FEA_PROD_WPLOAD)" >&2 exit 2 fi RELEASE_DIR="logs/release-issue-181" mkdir -p "$RELEASE_DIR/backups" STAMP="$(date -u +%Y%m%dT%H%M%SZ)" ORIGINS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880 54914) AUDIO_IDS=(54875 54902 54903 54883 54884 54885 54886 54866 54867 54868 54869 54870 54871 54872 54873 54880) if [[ "$MODE" == "apply" && "${FEA_RELEASE_CONFIRM:-}" != "PUBLISH_ISSUE_181" ]]; then echo "ABORT: para escribir en producción usa:" >&2 echo " FEA_RELEASE_CONFIRM=PUBLISH_ISSUE_181 $0 --apply" >&2 exit 2 fi preflight() { python3 - "${ORIGINS[@]}" <<'PY' import json, sys sys.path.insert(0, 'scripts') import sync_translations_to_prod as sync ids = [int(x) for x in sys.argv[1:]] rows = [] for pid in ids: d = json.loads(sync.prod_helper('read_full', str(pid))) rows.append({'id': pid, 'status': d['status'], 'lang': d['lang'], 'translations': d.get('translations', {})}) assert len(rows) == 17 assert all(r['status'] == 'publish' and r['lang'] == 'es' for r in rows), rows # Los 16 artículos no deben tener traducciones aún; la carta tampoco antes del primer release. assert all(r['translations'] == {'es': r['id']} for r in rows), rows print(json.dumps(rows, ensure_ascii=False, indent=2)) print('preflight=PASS sources=17') PY } backup_prod_state() { python3 - "$RELEASE_DIR/backups/prod-before-$STAMP.json" "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY' import json, sys out = sys.argv[1] sep = sys.argv.index('--') origins = [int(x) for x in sys.argv[2:sep]] audio_ids = [int(x) for x in sys.argv[sep+1:]] sys.path.insert(0, 'scripts') import sync_translations_to_prod as translations import sync_audio_to_prod as audio payload = { 'origins': {str(pid): json.loads(translations.prod_helper('read_full', str(pid))) for pid in origins}, 'audio_before': { str(pid): { 'url': audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip(), 'voice': audio.prod_helper('getmeta', str(pid), 'fea_audio_voice').strip(), 'done': audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip(), } for pid in audio_ids }, } with open(out, 'w', encoding='utf-8') as fh: json.dump(payload, fh, ensure_ascii=False, indent=2) print(out) PY } verify_release() { python3 - "${ORIGINS[@]}" -- "${AUDIO_IDS[@]}" <<'PY' import json, sys, time sep = sys.argv.index('--') origins = [int(x) for x in sys.argv[1:sep]] audio_ids = [int(x) for x in sys.argv[sep+1:]] sys.path.insert(0, 'scripts') import sync_translations_to_prod as translations import sync_audio_to_prod as audio def read_post(pid): """Lectura server-side resiliente ante una respuesta SSH/PHP vacía transitoria.""" last = '' for attempt in range(1, 4): raw = translations.prod_helper('read_full', str(pid)).strip() if raw: try: return json.loads(raw) except json.JSONDecodeError as exc: last = f'JSON inválido intento {attempt}: {exc}; prefijo={raw[:160]!r}' else: last = f'respuesta vacía intento {attempt}' time.sleep(attempt) raise AssertionError(f'No se pudo leer post #{pid}: {last}') for pid in origins: d = read_post(pid) group = d.get('translations', {}) assert set(group) == {'es', 'en', 'fr', 'it', 'pt'}, (pid, group) for lang, tid in group.items(): td = read_post(tid) assert td['lang'] == lang and td['status'] == 'publish', (pid, lang, tid, td['lang'], td['status']) for pid in audio_ids: url = audio.prod_helper('getmeta', str(pid), 'fea_audio_url').strip() done = audio.prod_helper('getmeta', str(pid), 'fea_audio_done').strip() assert url.endswith(f'/wp-content/uploads/tts/{pid}.mp3') and done == '1', (pid, url, done) print('verification=PASS groups=17 translations=68 audio=16') PY } if [[ "$MODE" == "verify" ]]; then echo "== Verificación server-side posterior (solo lectura) ==" verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt" echo "VERIFICACIÓN ISSUE #181 COMPLETADA" exit 0 fi echo "== Preflight server-side ($MODE): 17 fuentes ES ==" preflight | tee "$RELEASE_DIR/preflight-$MODE-$STAMP.json" if [[ "$MODE" == "dry-run" ]]; then : > "$RELEASE_DIR/translations-dry-run-$STAMP.log" for origin in "${ORIGINS[@]}"; do FEA_SYNC_STATUS=publish \ FEA_SYNC_LOG="$RELEASE_DIR/translations-dry-run-$STAMP.log" \ FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \ python3 scripts/sync_translations_to_prod.py --origin "$origin" --dry-run done FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-dry-run-$STAMP.log" \ FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \ python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")" --dry-run | tee "$RELEASE_DIR/audio-dry-run-$STAMP.log" echo "DRY-RUN terminado: 68 traducciones publicables + 16 audios planificados." exit 0 fi echo "== Backup server-side previo ==" backup_prod_state | tee "$RELEASE_DIR/backup-path-$STAMP.txt" echo "== Subiendo 68 traducciones como publish ==" for origin in "${ORIGINS[@]}"; do FEA_SYNC_STATUS=publish \ FEA_SYNC_LOG="$RELEASE_DIR/translations-apply-$STAMP.log" \ FEA_SYNC_STATE="$RELEASE_DIR/translations-$origin-state.json" \ python3 scripts/sync_translations_to_prod.py --origin "$origin" done echo "== Subiendo 16 audios ==" FEA_AUDIO_SYNC_LOG="$RELEASE_DIR/audio-apply-$STAMP.log" \ FEA_AUDIO_SYNC_STATE="$RELEASE_DIR/audio-state.json" \ python3 scripts/sync_audio_to_prod.py --ids "$(IFS=,; echo "${AUDIO_IDS[*]}")" echo "== Verificación server-side posterior ==" verify_release | tee "$RELEASE_DIR/verification-$STAMP.txt" echo "RELEASE ISSUE #181 COMPLETADO"