Apunta los scripts de sync prod al Hetzner nuevo y suma trabajo pendiente

- sync_translations_to_prod.py / sync_audio_to_prod.py / sync_carta_from_prod.py:
  migran de FEA_PROD_HOST/PASS (CDMON, password) a FEA_PROD_SSH_HOST/PASS +
  FEA_PROD_DOCKER_CONTAINER (Hetzner/Coolify, auth por clave), con wrapping
  docker exec y el fix del bug de redirecciones (wc -c/cat) resuelto en el host
  en vez de dentro del contenedor.
- fea_translate_helper.php: subcomando clone_new para clonar en ID local nuevo
  cuando el ID de prod ya está ocupado localmente.
- translate_post.py: --dry-run.
- tts_produce.py: --allow-default-voice (voz Nico solo si se permite
  explícitamente para autores sin voz clonada) + fix voice_for_author.
- minimax_tts.py: parámetro speed en t2a/_synth_chunk.
- mirror-antiguo/deploy/nginx-mirror.conf: redirects de URLs históricas de
  catálogo y vista imprimible EFFA.
- Importaciones humanas de Pagola (carta 738) + scripts/manifest de la
  fase A Enrique, y release_issue181_prod.sh / cdmon-retire-fewp1.sh.
- .gitignore: excluye docs/backups/ (dumps SQL, mismo motivo que backups/*).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 07:09:29 -04:00
parent 159080f0c8
commit df7bf98ef1
21 changed files with 980 additions and 110 deletions
+167
View File
@@ -0,0 +1,167 @@
#!/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"