Volver al índice

docs/publicar-raps.py

"""Compilación y publicación repetible de Raps; nunca publica una etapa fallida."""
from __future__ import annotations

import argparse
import hashlib
import inspect
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
import zipfile

ROOT = Path(__file__).resolve().parents[1]
WORK = ROOT / 'build/publicacion'
STATE = WORK / 'etapas.json'
SUMMARY = WORK / 'resultado.json'
PYTHON = sys.executable
JAVA = Path('C:/Program Files/Eclipse Adoptium/jdk-17.0.20.8-hotspot/bin')
SITES = Path.home() / '.codex/plugins/cache/openai-curated-remote/sites/0.1.75/scripts/site-workflow.mjs'


# HUELLAS: una etapa sólo se reutiliza si sus entradas y salidas siguen iguales.
def sha(path: Path) -> str:
    value = hashlib.sha256()
    with path.open('rb') as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b''):
            value.update(chunk)
    return value.hexdigest()


def files(paths):
    result = []
    for path in map(Path, paths):
        if path.is_dir():
            result.extend(p for p in path.rglob('*') if p.is_file() and '__pycache__' not in p.parts)
        elif path.is_file():
            result.append(path)
        else:
            raise FileNotFoundError(path)
    return sorted(set(result))


def digest(paths) -> str:
    return hashlib.sha256(json.dumps([(str(p), sha(p)) for p in files(paths)]).encode()).hexdigest()


def write_json(path, value):
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_suffix(path.suffix + '.tmp')
    temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    temporary.replace(path)


# HERRAMIENTAS: usar ejecutables reales evita los problemas de .cmd y comillas.
def executable(name):
    found = shutil.which(name + '.exe') or shutil.which(name)
    if found and Path(found).suffix.lower() == '.cmd':
        match = re.search(r'"([^"\r\n]+\.exe)"', Path(found).read_text(encoding='utf-8'))
        if match and Path(match[1]).is_file():
            return match[1]
    if not found:
        raise RuntimeError('No se encontró ' + name)
    return found


def ps(script, *arguments):
    return [executable('pwsh'), '-NoProfile', '-File', str(ROOT / script), *map(str, arguments)]


def build_sources():
    return [ROOT / 'src', ROOT / 'build.gradle', ROOT / 'settings.gradle', ROOT / 'gradle.properties',
            ROOT / 'gradle/wrapper', ROOT / 'gradlew.bat', ROOT / 'compilar.ps1']


def server_test_sources():
    backend = [p for p in (ROOT / 'src/main/java').rglob('*.java')
               if '/horror/client/' not in p.as_posix() and p.name != 'HostSkinAtlas.java']
    infrastructure = [p for p in build_sources() if p != ROOT / 'src']
    resources = [p for p in (ROOT / 'src/main/resources').rglob('*')
                 if p.is_file() and '/textures/' not in p.as_posix() and '/sounds/' not in p.as_posix()]
    return backend + infrastructure + resources + [ROOT / 'config/raps']


def stop_owned(process):
    # Sólo terminar el árbol que inició este publicador, nunca Java/Chrome por nombre.
    if process.poll() is None:
        if os.name == 'nt':
            subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
        else:
            process.kill()
        process.wait(timeout=15)


# EJECUCIÓN: límite total, registro por etapa y avance breve cada 30 segundos.
def run(command, log, limit=600, cwd=ROOT, input_text=None):
    environment = os.environ.copy()
    git_bin = Path('C:/Program Files/Git/bin')
    if git_bin.exists():
        environment['PATH'] = str(git_bin) + os.pathsep + environment.get('PATH', '')
    if any('site-workflow.mjs' in str(part) for part in command):
        # GNU tar de Git Bash interpreta C: como host remoto si no se fuerza ruta local.
        environment['TAR_OPTIONS'] = '--force-local'
    environment['PYTHONUNBUFFERED'] = '1'
    started = time.monotonic()
    next_notice = started + 30
    check_failure_at = started + 1
    game_tests = any('runGameTestServer' in str(part) for part in command)
    with Path(log).open('w', encoding='utf-8') as stream:
        process = subprocess.Popen(list(map(str, command)), cwd=cwd, env=environment,
                                   stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL,
                                   stdout=stream, stderr=subprocess.STDOUT,
                                   creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
        try:
            if input_text is not None:
                process.stdin.write(input_text.encode('utf-8'))
                process.stdin.close()
            while process.poll() is None:
                now = time.monotonic()
                if now - started > limit:
                    raise TimeoutError(f'La etapa excedió {limit}s. Registro: {log}')
                if game_tests and now >= check_failure_at:
                    stream.flush()
                    current = Path(log).read_text(encoding='utf-8', errors='replace')
                    if re.search(r'\[minecraft/LogTestReporter\].* failed!', current):
                        raise RuntimeError('Forge informó una prueba fallida; publicación detenida. Registro: ' + str(log))
                    check_failure_at = now + 1
                if now >= next_notice:
                    print(f'  {Path(log).stem}: {int(now - started)}s', flush=True)
                    next_notice = now + 30
                time.sleep(.2)
            if process.returncode:
                tail = Path(log).read_text(encoding='utf-8', errors='replace').splitlines()[-12:]
                raise RuntimeError(f'Etapa fallida ({process.returncode}): {log}\n' + '\n'.join(tail))
        finally:
            stop_owned(process)
    return time.monotonic() - started


class Publisher:
    def __init__(self, force=False, work=WORK):
        self.work = work
        self.work.mkdir(parents=True, exist_ok=True)
        self.state_path = work / 'etapas.json'
        self.state = json.loads(self.state_path.read_text(encoding='utf-8')) if self.state_path.exists() else {}
        self.force = force
        self.done = []

    def phase(self, name, command, inputs, outputs=(), limit=600, before=None, after=None):
        key = digest(inputs)
        invocation = list(map(str, command))
        hooks = [inspect.getsource(hook) if hook is not None else None for hook in (before, after)]
        output_paths = list(map(Path, outputs))
        previous = self.state.get(name, {})
        if (not self.force and previous.get('inputs') == key
                and previous.get('command') == invocation and previous.get('hooks') == hooks
                and all(p.exists() for p in output_paths)
                and previous.get('outputs') == {str(p): sha(p) for p in output_paths}):
            print(name + ': reutilizado', flush=True)
            self.done.append({'stage': name, 'status': 'cached'})
            return
        print(name + ': iniciando', flush=True)
        if before:
            before()
        seconds = run(command, self.work / (name + '.log'), limit)
        if after:
            after(self.work / (name + '.log'))
        if digest(inputs) != key:
            raise RuntimeError('Cambió una entrada durante ' + name + '; retomar con las fuentes estables.')
        self.state[name] = {'inputs': key, 'command': invocation, 'hooks': hooks,
                            'outputs': {str(p): sha(p) for p in output_paths},
                            'seconds': round(seconds, 2), 'completed': time.strftime('%Y-%m-%dT%H:%M:%S')}
        write_json(self.state_path, self.state)
        self.done.append({'stage': name, 'status': 'passed', 'seconds': round(seconds, 2)})
        print(name + ': completo', flush=True)


# PRUEBAS DE SERVIDOR: conservar el mundo local anterior y exigir el resultado Forge.
def prepare_test_world():
    world = (ROOT / 'run/world').resolve()
    if world.parent != (ROOT / 'run').resolve():
        raise RuntimeError('Ruta de mundo inesperada')
    if world.exists():
        world.rename(world.with_name('world-before-release-' + time.strftime('%Y%m%d-%H%M%S')))


def check_game_tests(log):
    text = log.read_text(encoding='utf-8', errors='replace')
    match = re.search(r'All (\d+) required tests passed', text)
    if not match or 'BUILD SUCCESSFUL' not in text:
        raise RuntimeError('Forge no confirmó todas las pruebas requeridas: ' + str(log))
    write_json(WORK / 'pruebas-servidor.json', {'passed': True, 'required_tests': int(match[1])})


# INSTALACIÓN: respaldar el JAR anterior y comparar el archivo copiado.
def install_jar():
    source = ROOT / 'build/libs/Raps.jar'
    with zipfile.ZipFile(source) as archive:
        if archive.testzip() is not None:
            raise RuntimeError('El JAR contiene una entrada dañada')
    for target in [ROOT / 'dist/Raps.jar', ROOT.parent / 'mods/Raps.jar']:
        target.parent.mkdir(parents=True, exist_ok=True)
        if target.exists() and sha(target) != sha(source):
            backup = ROOT / 'respaldos' / ('publicacion-' + time.strftime('%Y%m%d-%H%M%S')) / target.parent.name
            backup.mkdir(parents=True, exist_ok=True)
            shutil.copy2(target, backup / target.name)
        temporary = target.with_suffix('.jar.tmp')
        shutil.copy2(source, temporary)
        if sha(temporary) != sha(source):
            raise RuntimeError('Copia incorrecta: ' + str(target))
        temporary.replace(target)
    return sha(source)


# INFORME: documentación de la entrega tomada de los resultados, sin cifras fijas.
def delivery_report(jar_hash, server_tests_skipped=False):
    def read(relative):
        path = ROOT / relative
        return json.loads(path.read_text(encoding='utf-8')) if path.exists() else {}
    server = read('build/publicacion/pruebas-servidor.json')
    motion = read('docs/parasito-movimiento-validacion.json')
    native = read('build/jar-native-audit/result.json')
    remote = read('build/publicacion/exaroton.json')
    remote_current = remote.get('status') == 'verified' and remote.get('sha256') == jar_hash
    release_state = json.loads(STATE.read_text(encoding='utf-8'))
    report = {
        'created': release_state['compilar']['completed'], 'jar_sha256': jar_hash,
        'jar_bytes': (ROOT / 'build/libs/Raps.jar').stat().st_size,
        'rig_sha256': sha(ROOT / 'src/main/java/ar/com/companeros/horror/client/ParasiteRig.java'),
        'model_sha256': sha(ROOT / 'src/main/java/ar/com/companeros/horror/client/ParasiteModel.java'),
        'server_tests': {'status': 'skipped'} if server_tests_skipped else server,
        'locomotion': motion,
        'native_source_equivalence': native,
        'exposed_skin': read('build/exposed-skin-audit/result.json'),
        'continuity': read('build/motion-support-audit/current.json'),
        'media': read('docs/parasito-preview-validacion.json'),
        'voice_studio': read('docs/voice-studio/validacion.json'),
        'exaroton': remote if remote_current else {'status': 'not_verified_for_this_jar'}
    }
    write_json(ROOT / 'docs/entrega-actual.json', report)
    count = server.get('required_tests', 'sin registro')
    tests = 'Pruebas de servidor omitidas en esta ejecución.' if server_tests_skipped else f'Forge confirmó {count} pruebas requeridas.'
    remote_text = ('El JAR de Exaroton fue descargado y su hash coincide con esta entrega. El servidor permanece apagado.'
                   if remote_current else 'La instalación de Exaroton no está verificada para este JAR.')
    markdown = f'''# Entrega actual de Raps

Generada el {report['created']} desde los resultados de esta compilación.

JAR: **{report['jar_bytes']} bytes**, SHA256 `{jar_hash}`.

{tests} La auditoría de locomoción comprobó {motion.get('checked_samples', 'sin registro')} muestras en {', '.join(motion.get('selected_modes', []))}. La equivalencia fuente/JAR verificó {native.get('native_cli', {}).get('native_frames_per_target', 'sin registro')} muestras nativas. Los informes completos están en [entrega-actual.json](entrega-actual.json).

El acecho ya no usa un apoyo ficticio. Las palmas consultan colisiones reales; adquirir y abandonar el apoyo es gradual. El plegado mezcla orientaciones continuas de los brazos y conserva la posición final del arrastre. La revisión de contorsiones sigue requiriendo observación dentro del cliente; la auditoría de locomoción no afirma ausencia absoluta de intersecciones en todas las poses.

Luna puede solicitar `auto`, `hunched`, `all_fours`, `crawl` y `wall_brace`. Las preferencias caducan y el entorno decide si son posibles. La cadencia, el presupuesto y el Gateway permanecen vigentes.

El estudio local transforma grabaciones e incorpora síntesis Piper bajo demanda. El preset utiliza el tratamiento de la voz aprobado; aplicar el mismo filtro a dos voces no garantiza identidad vocal idéntica.

El publicador instala el JAR local con respaldo y prepara las galerías. {remote_text}
'''
    (ROOT / 'docs/ENTREGA-ACTUAL.md').write_text(markdown, encoding='utf-8')


def check_server_ready(server_tests_required=True):
    state = json.loads(STATE.read_text(encoding='utf-8'))
    jar = ROOT / 'build/libs/Raps.jar'
    required_stages = [('compilar', build_sources())]
    if server_tests_required:
        required_stages.append(('pruebas-servidor', server_test_sources()))
    for name, inputs in required_stages:
        stage = state.get(name, {})
        if stage.get('inputs') != digest(inputs):
            raise RuntimeError('Las fuentes cambiaron después de ' + name)
        if any(not Path(p).is_file() or sha(Path(p)) != value for p, value in stage.get('outputs', {}).items()):
            raise RuntimeError('El artefacto cambió después de ' + name)
    audit = json.loads((ROOT / 'build/jar-native-audit/result.json').read_text(encoding='utf-8'))
    if not audit.get('passed') or audit.get('jar_sha256') != sha(jar):
        raise RuntimeError('Falta comprobar la equivalencia del JAR actual con las fuentes')
    print('Entrega actual compilada, probada y coincidente con su fuente.')


# SITES: preparar el mismo paquete; la credencial sólo entra por stdin.
def sites_package():
    payload = json.loads(sys.stdin.readline())
    credential = payload['credential']
    hosting = json.loads((ROOT / 'galeria-chatgpt/.openai/hosting.json').read_text(encoding='utf-8'))
    project_id = hosting['project_id']
    checkout = ROOT / 'galeria-chatgpt'
    command = [executable('node'), str(SITES), '--project-id', project_id]
    opening_log = WORK / 'sites-opening.log'
    run(command, opening_log, 180, checkout, json.dumps({'credential': credential}) + '\n')
    source = next(json.loads(line) for line in opening_log.read_text(encoding='utf-8').splitlines()
                  if line.startswith('{') and 'checkout_path' in line)
    # Archivos de publicación fuera del checkout: nunca se añaden al commit del sitio.
    archive = WORK / 'galeria-chatgpt.tar.gz'
    publishing_log = WORK / 'sites-package.log'
    run(command, publishing_log, 600, checkout,
        json.dumps({'credential': credential, 'source': source, 'archivePath': archive.as_posix()}) + '\n')
    result = next(json.loads(line) for line in publishing_log.read_text(encoding='utf-8').splitlines()
                  if line.startswith('{') and 'archive' in line)
    write_json(WORK / 'sites-paquete.json', result)
    print(json.dumps(result), flush=True)


def self_test():
    # El fixture usa sólo archivos temporales; no compila, instala ni publica.
    import tempfile
    with tempfile.TemporaryDirectory(prefix='raps-publicador-') as folder:
        base = Path(folder)
        entry, output = base / 'input.txt', base / 'output.txt'
        entry.write_text('uno', encoding='utf-8')
        publisher = Publisher(work=base)
        command = [PYTHON, '-c', 'from pathlib import Path; Path(r"' + str(output) + '").write_text("ok")']
        publisher.phase('fixture', command, [entry], [output])
        publisher.phase('fixture', command, [entry], [output])
        assert publisher.done[-1]['status'] == 'cached'
        output.write_text('alterado', encoding='utf-8')
        publisher.phase('fixture', command, [entry], [output])
        assert output.read_text() == 'ok' and publisher.done[-1]['status'] == 'passed'
        changed_command = [PYTHON, '-c', 'from pathlib import Path; Path(r"' + str(output) + '").write_text("nuevo")']
        publisher.phase('fixture', changed_command, [entry], [output])
        assert output.read_text() == 'nuevo' and publisher.done[-1]['status'] == 'passed'
        entry.write_text('dos', encoding='utf-8')
        publisher.phase('fixture', command, [entry], [output])
        try:
            publisher.phase('fallo', [PYTHON, '-c', 'raise SystemExit(7)'], [entry])
        except RuntimeError:
            pass
        else:
            raise AssertionError('No bloqueó una etapa fallida')
        assert 'fallo' not in publisher.state
        try:
            run([PYTHON, '-c', 'import time; time.sleep(10)'], base / 'timeout.log', .4)
        except TimeoutError:
            pass
        else:
            raise AssertionError('No aplicó el límite de tiempo')
    print('Autoprueba: caché, invalidación, fallo y tiempo límite aprobados.')


# ENTREGA: las etapas posteriores nunca corren si una validación anterior falla.
def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--prepare-only', action='store_true')
    parser.add_argument('--force', action='store_true')
    parser.add_argument('--skip-server-tests', action='store_true')
    parser.add_argument('--self-test', action='store_true')
    parser.add_argument('--sites-package', action='store_true')
    parser.add_argument('--write-report', action='store_true')
    parser.add_argument('--server', action='store_true')
    parser.add_argument('--server-only', action='store_true')
    parser.add_argument('--check-server-ready', action='store_true')
    args = parser.parse_args()
    WORK.mkdir(parents=True, exist_ok=True)
    if args.self_test:
        self_test()
        return
    if args.sites_package:
        sites_package()
        return
    if args.check_server_ready:
        check_server_ready()
        return
    if args.server_only:
        run(ps('publicar-servidor-raps.ps1'), WORK / 'exaroton.log', 300)
        return
    if args.write_report:
        delivery_report(sha(ROOT / 'build/libs/Raps.jar'), args.skip_server_tests)
        return
    publisher = Publisher(args.force)
    lock = (WORK / 'publicador.lock').open('a+b')
    if os.name == 'nt':
        import msvcrt
        lock.seek(0)
        try:
            msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
        except OSError:
            raise RuntimeError('Ya hay un publicador activo')
    try:
        source = ROOT / 'src'
        jar = ROOT / 'build/libs/Raps.jar'
        # Estas herramientas viven fuera de build para sobrevivir a Gradle clean.
        required = ['docs/publicacion/verify-final-jar.py', 'docs/publicacion/JarRigExportAudit.java',
                    'docs/publicacion/run-exposed-skin-audit.py', 'docs/publicacion/ExposedSkinAudit.java',
                    'docs/publicacion/check-published-gallery.cjs']
        for relative in required:
            if not (ROOT / relative).is_file():
                raise RuntimeError('Falta una herramienta de publicación: ' + relative)
        build_inputs = build_sources()
        publisher.phase('compilar', ps('compilar.ps1', '-Tarea', 'build', '-SinRed'), build_inputs, [jar], 900)
        if not args.skip_server_tests:
            publisher.phase('pruebas-servidor', ps('compilar.ps1', '-Tarea', 'runGameTestServer', '-SinRed'),
                            server_test_sources(), [WORK / 'pruebas-servidor.json'], 900,
                            prepare_test_world, check_game_tests)
        rig = source / 'main/java/ar/com/companeros/horror/client/ParasiteRig.java'
        model = source / 'main/java/ar/com/companeros/horror/client/ParasiteModel.java'
        textures = source / 'main/resources/assets/companeros/textures/entity'
        # El auditor consume clases propias del visor, distintas de las de Gradle.
        preview_classes = ROOT / 'build/parasite-preview-classes'
        preview_classes.mkdir(parents=True, exist_ok=True)
        publisher.phase('compilar-visor', [JAVA / 'javac.exe', '-encoding', 'UTF-8', '-d', preview_classes, rig],
                        [rig], [preview_classes / ('ar/com/companeros/horror/client/ParasiteRig' + name + '.class')
                                for name in ['', '$Pose', '$Bone', '$Box', '$State', '$Bounds']], 90)
        publisher.phase('movimiento', [PYTHON, ROOT / 'docs/validate-parasite-preview.py', '--modes', 'stalk,crawl3,climb'],
                        [rig, ROOT / 'docs/validate-parasite-preview.py'], [ROOT / 'docs/parasito-movimiento-validacion.json'], 240)
        native_audit = ROOT / 'docs/publicacion/verify-final-jar.py'
        publisher.phase('fuente-jar', [PYTHON, native_audit, '--jar', jar, '--source-only'],
                        [rig, jar, native_audit, ROOT / 'docs/publicacion/JarRigExportAudit.java'],
                        [ROOT / 'build/jar-native-audit/result.json'], 240)
        skin_audit = ROOT / 'docs/publicacion/run-exposed-skin-audit.py'
        publisher.phase('piel', [PYTHON, skin_audit],
                        [source / 'main/java/ar/com/companeros/identity/HostSkinAtlas.java', skin_audit,
                         ROOT / 'docs/publicacion/ExposedSkinAudit.java', ROOT / 'docs/skin-huesped-neon.png'],
                        [ROOT / 'build/exposed-skin-audit/result.json'], 180)
        modes = ['idle', 'lore', 'chase', 'chase4', 'drag', 'crawl', 'crawl2', 'crawl3', 'crawl4', 'fold', 'turn', 'climb', 'attack', 'friendly']
        media_outputs = [ROOT / 'docs' / ('parasito-' + mode + '.mp4') for mode in modes]
        media_outputs += [ROOT / 'docs/parasito-modelos-origen.json', ROOT / 'docs/parasito-modelos.png',
                          ROOT / 'docs/modelos-3d/parasito-humanoide.obj', ROOT / 'docs/modelos-3d/parasito-aracnido.obj']
        stills = ['rostro', 'evolucion', 'sensible', *['fase-' + str(n) for n in range(5)],
                  *['prefab-' + material for material in ['obsidian', 'iron', 'diamond', 'copper']]]
        media_outputs += [ROOT / 'docs' / ('parasito-' + name + '.png') for name in stills]
        media_outputs += [ROOT / 'docs' / name for name in ['parasito-modelos.html', 'parasito-animaciones.mp4',
                          'parasito-chase.gif', 'parasito-lore.gif', 'parasito-encuentro-voz.mp4', 'parasito-encuentro-completo.mp4']]
        objects = ['parasito-humanoide', 'parasito-encuentro', 'parasito-aracnido', 'parasito-sensible', 'parasito-huesped-inicial']
        media_outputs += [ROOT / 'docs/modelos-3d' / (name + '.' + ext) for name in objects for ext in ['obj', 'json']]
        media_outputs += [ROOT / 'docs/modelos-3d' / (name + '.' + ext)
                          for name in ['parasite', 'parasite_initial', 'parasite_friendly'] for ext in ['mtl', 'png']]
        media_outputs += [ROOT / 'docs/modelos-3d' / ('parasito-prefab-' + name + '.obj') for name in ['obsidian', 'iron', 'diamond', 'copper']]
        publisher.phase('medios', [PYTHON, ROOT / 'docs/render-parasite-preview.py'],
                        [rig, model, textures, ROOT / 'docs/render-parasite-preview.py', ROOT / 'docs/parasito-voz.mp3'],
                        media_outputs, 1200)
        publisher.phase('corredor', [PYTHON, ROOT / 'docs/render-corridor-demo.py'],
                        [rig, model, textures, ROOT / 'docs/render-corridor-demo.py', ROOT / 'docs/render-parasite-preview.py'],
                        [ROOT / 'docs/parasito-crawl3-paredes-origen.json', ROOT / 'docs/parasito-crawl3-paredes.mp4'], 300)
        publisher.phase('escalada', [PYTHON, ROOT / 'docs/render-climbing-demo.py', '--expected-rig-sha256', sha(rig)],
                        [rig, model, textures, ROOT / 'docs/render-climbing-demo.py', ROOT / 'docs/render-parasite-preview.py'],
                        [ROOT / 'docs/parasito-climb-pared-origen.json', ROOT / 'docs/parasito-climb-pared.mp4'], 300)
        publisher.phase('validar-medios', [PYTHON, ROOT / 'docs/validate-parasite-assets.py'],
                        [rig, model, ROOT / 'docs/validate-parasite-assets.py'] +
                        [p for p in (ROOT / 'docs').glob('parasito-*') if p.name != 'parasito-preview-validacion.json'],
                        [ROOT / 'docs/parasito-preview-validacion.json'], 300)
        # Revalidar antes de instalar: otro proceso pudo cambiar fuentes durante los renders.
        check_server_ready(not args.skip_server_tests)
        jar_hash = install_jar()
        if args.server:
            if args.prepare_only or args.skip_server_tests:
                raise RuntimeError('Publicar el servidor requiere una entrega con pruebas y publicación habilitadas.')
            run(ps('publicar-servidor-raps.ps1'), WORK / 'exaroton.log', 300)
        delivery_report(jar_hash, args.skip_server_tests)
        run(ps('publicar-visor.ps1', '-SoloPreparar'), WORK / 'preparar-web.log', 300)
        run(ps('preparar-galeria.ps1'), WORK / 'escritorio.log', 180)
        run(ps('preparar-auditoria-sites.ps1'), WORK / 'preparar-sites.log', 180)
        site_inputs = [ROOT / 'dist/visor']
        if not args.prepare_only:
            wrangler = Path.home() / 'Desktop/Rapsodia/lista-azul-web/node_modules/wrangler/bin/wrangler.js'
            for branch in ['main', 'preview']:
                publisher.phase('cloudflare-' + branch,
                                [executable('node'), wrangler, 'pages', 'deploy', ROOT / 'dist/visor',
                                 '--project-name', 'raps-huesped', '--branch', branch, '--commit-dirty=true'],
                                site_inputs + [wrangler], limit=300)
            run([executable('node'), ROOT / 'docs/publicacion/check-published-gallery.cjs', '--cloudflare-only'],
                WORK / 'verificar-web.log', 300)
        result = {'status': 'prepared' if args.prepare_only else 'published', 'jar_sha256': jar_hash,
                  'jar': str(jar), 'server_tests': 'skipped' if args.skip_server_tests else 'passed',
                  'stages': publisher.done, 'sites': {'status': 'prepared-for-authenticated-package',
                   'command': [PYTHON, str(Path(__file__)), '--sites-package']},
                  'urls': ['https://raps-huesped.pages.dev/?v=' + jar_hash[:8] + '#movimiento',
                           'https://preview.raps-huesped.pages.dev/?v=' + jar_hash[:8] + '#movimiento']}
        write_json(SUMMARY, result)
        print(json.dumps(result, ensure_ascii=False), flush=True)
    except Exception as error:
        write_json(SUMMARY, {'status': 'failed', 'error': str(error), 'completed_stages': publisher.done})
        raise
    finally:
        lock.close()


if __name__ == '__main__':
    main()