Parte: 19 — Ingeniería de producción, backend y confiabilidad · Fuente: Semantic Versioning 2.0.0 · Documentación de GitHub Actions y de exportación de Godot 4 ⏱️ Duración estimada: 120 min · Nivel: Avanzado
Convertir "exportar el juego" en un proceso de ingeniería: versionado con significado, builds reproducibles, matriz de plataformas, artefactos con checksum, firma, símbolos archivados y canales de distribución (dev, alpha, beta, producción). La clase 261 enseñó a exportar por línea de comandos; esta clase convierte ese comando en una cadena de publicación de la que puedes fiarte.
La pregunta que hay que poder responder en cualquier momento es: "un jugador reporta un crash en la 1.4.2; ¿de qué commit salió esa build, con qué símbolos se lee su pila y cómo la reproduzco?". Si la respuesta no es inmediata, el problema no es el crash: es la cadena de release.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Versionado semántico | Comunica el impacto de un cambio sin leer el changelog. |
| 2 | Versión en el binario | Sin ella, un informe de crash no se puede situar. |
| 3 | Trazabilidad build↔commit | Es lo que permite reproducir un fallo. |
| 4 | Matriz de build | Varias plataformas, un solo proceso. |
| 5 | Artefacto y manifiesto | Lo que se publica debe poder verificarse. |
| 6 | Checksum | Detecta corrupción y descargas incompletas. |
| 7 | Firma | Lo que evita avisos del sistema y suplantaciones. |
| 8 | Símbolos | Sin ellos los crashes son ilegibles. |
| 9 | Canales | Separan a quien quiere estabilidad de quien quiere novedades. |
| 10 | Promoción | Un artefacto se promociona, no se reconstruye. |
MAYOR.MENOR.PARCHE con reglas sobre cuándo sube cada uno. Clave: comunica compatibilidad, no cantidad de trabajo.dev, alpha, beta, production). Clave: cada uno con su público y su ritmo.Godot 4.x con plantillas de exportación, GitHub Actions y Python para los scripts. Trabajaremos en .github/workflows/release.yml y scripts/release/. Documentación: exportación desde CLI en Godot, SemVer 2.0.0 y GitHub Actions. Si tu repositorio ya tiene CI (Parte 15), esta clase la amplía; no la sustituye.
| Cambio | Sube | Por qué |
|---|---|---|
| Corregir un bug sin cambiar comportamiento | PARCHE | 1.4.1 → 1.4.2 |
| Añadir contenido o una función | MENOR | 1.4.2 → 1.5.0 |
| Cambio que rompe saves antiguos | MAYOR | 1.5.0 → 2.0.0 |
| Cambio que rompe mods (API) | MAYOR | Ver clase 309 |
| Cambio que rompe el protocolo de red | MAYOR | Clientes viejos no podrán jugar |
| Rebalanceo de números por remote config | Ninguno | No es una build |
Esa fila del save es la que sorprende: si subes SAVE_VERSION sin migración, has roto la compatibilidad y eso es un cambio mayor. Con migración (clase 307), es menor.
#!/usr/bin/env python3
"""Escribe la información de versión en el proyecto antes de exportar."""
import json, os, subprocess, sys, datetime
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def git(*args):
return subprocess.check_output(["git", *args], cwd=RAIZ, text=True).strip()
def main():
version = sys.argv[1] if len(sys.argv) > 1 else "0.0.0-dev"
info = {
"version": version,
"commit": git("rev-parse", "HEAD"),
"commit_corto": git("rev-parse", "--short", "HEAD"),
"rama": git("rev-parse", "--abbrev-ref", "HEAD"),
# SOURCE_DATE_EPOCH: si está definida, se usa esa fecha en vez de "ahora".
# Es lo que permite que dos builds del mismo commit sean idénticas.
"fecha": datetime.datetime.fromtimestamp(
int(os.environ.get("SOURCE_DATE_EPOCH", 0)) or
int(git("log", "-1", "--format=%ct")),
datetime.timezone.utc).isoformat(),
"sucio": bool(git("status", "--porcelain")),
}
if info["sucio"]:
print("::warning::el árbol de trabajo tiene cambios sin commitear")
destino = os.path.join(RAIZ, "version.json")
with open(destino, "w", encoding="utf-8", newline="\n") as f:
json.dump(info, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"version.json: {info['version']} ({info['commit_corto']})")
return 0
if __name__ == "__main__":
sys.exit(main())
# Y en el juego, accesible desde cualquier sitio y visible para el jugador.
class_name Version
extends RefCounted
static var _info := {}
static func info() -> Dictionary:
if _info.is_empty():
var f := FileAccess.open("res://version.json", FileAccess.READ)
_info = JSON.parse_string(f.get_as_text()) if f else {"version": "desconocida"}
return _info
static func cadena() -> String:
# Esta cadena va en el menú, en los crash reports y en la telemetría. Es lo
# que convierte "me falla el juego" en algo investigable.
return "%s (%s)" % [info().get("version", "?"), info().get("commit_corto", "?")]
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version: { description: "Versión (ej. 1.4.2)", required: true }
permissions:
contents: write
env:
GODOT_VERSION: "4.3"
jobs:
construir:
name: ${{ matrix.plataforma }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- plataforma: windows
runner: ubuntu-latest
preset: "Windows Desktop"
salida: "juego.exe"
- plataforma: linux
runner: ubuntu-latest
preset: "Linux/X11"
salida: "juego.x86_64"
- plataforma: web
runner: ubuntu-latest
preset: "Web"
salida: "index.html"
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0, lfs: true }
- name: Determinar versión
id: v
run: |
set -euo pipefail
VER="${{ github.event.inputs.version }}"
[ -z "$VER" ] && VER="${GITHUB_REF_NAME#v}"
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Sellar versión
run: |
# Fecha del commit, no la de ahora: es la mitad de la reproducibilidad.
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
python scripts/release/sellar_version.py "${{ steps.v.outputs.version }}"
- name: Instalar Godot y plantillas
run: bash scripts/release/instalar_godot.sh "$GODOT_VERSION"
- name: Exportar
run: |
set -euo pipefail
mkdir -p build/${{ matrix.plataforma }}
godot --headless --export-release "${{ matrix.preset }}" \
"build/${{ matrix.plataforma }}/${{ matrix.salida }}"
- name: Presupuestos de tamaño
run: python scripts/presupuestos_tamano.py
- name: Checksums y manifiesto
run: python scripts/release/manifiesto.py "${{ matrix.plataforma }}" "${{ steps.v.outputs.version }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.plataforma }}-${{ steps.v.outputs.version }}
path: build/${{ matrix.plataforma }}
retention-days: 90
#!/usr/bin/env python3
"""Genera checksums y manifiesto de una build."""
import hashlib, json, os, sys, datetime
def sha256(ruta):
h = hashlib.sha256()
with open(ruta, "rb") as f:
for bloque in iter(lambda: f.read(1 << 20), b""):
h.update(bloque)
return h.hexdigest()
def main():
plataforma, version = sys.argv[1], sys.argv[2]
carpeta = os.path.join("build", plataforma)
info = json.load(open("version.json", encoding="utf-8"))
archivos = []
for cur, _, ficheros in os.walk(carpeta):
for f in sorted(ficheros): # orden estable: el manifiesto también es reproducible
if f == "manifiesto.json":
continue
ruta = os.path.join(cur, f)
archivos.append({
"ruta": os.path.relpath(ruta, carpeta).replace("\\", "/"),
"bytes": os.path.getsize(ruta),
"sha256": sha256(ruta),
})
manifiesto = {
"version": version,
"plataforma": plataforma,
"commit": info["commit"],
"fecha": info["fecha"],
"godot": os.environ.get("GODOT_VERSION", ""),
"archivos": archivos,
"bytes_total": sum(a["bytes"] for a in archivos),
}
destino = os.path.join(carpeta, "manifiesto.json")
with open(destino, "w", encoding="utf-8", newline="\n") as f:
json.dump(manifiesto, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{plataforma}: {len(archivos)} archivo(s), "
f"{manifiesto['bytes_total'] / 1048576:.1f} MB")
return 0
if __name__ == "__main__":
sys.exit(main())
| Causa | Efecto | Solución |
|---|---|---|
| Marca de tiempo del build | Bytes distintos cada vez | SOURCE_DATE_EPOCH con la fecha del commit |
| Rutas absolutas incrustadas | Difiere entre máquinas | Rutas relativas; construir en ruta fija |
| Orden del sistema de archivos | Orden de recursos distinto | Ordenar explícitamente antes de empaquetar |
| Versión de Godot o plantillas | Todo distinto | Fijar versión exacta en el workflow |
| Contenido generado con RNG | Assets distintos | Semilla fija en los generadores |
| Locale de la máquina | Ordenación de cadenas distinta | Fijar LC_ALL=C |
Reproducibilidad total es difícil; lo que sí es alcanzable y ya vale mucho: dos builds del mismo commit deben tener el mismo tamaño y los mismos assets. Compara manifiestos, y si los checksums no coinciden, investiga cuál de las filas de arriba es.
- name: Firmar (Windows)
if: matrix.plataforma == 'windows'
env:
CERT_PFX_BASE64: ${{ secrets.CERT_PFX_BASE64 }}
CERT_PASS: ${{ secrets.CERT_PASS }}
run: |
# Los secretos NUNCA se imprimen ni se escriben en el log, y el
# certificado se borra al terminar el job.
if [ -z "${CERT_PFX_BASE64:-}" ]; then
echo "::warning::sin certificado: se publica sin firmar (solo para pruebas)"
exit 0
fi
echo "$CERT_PFX_BASE64" | base64 -d > /tmp/cert.pfx
osslsigncode sign -pkcs12 /tmp/cert.pfx -pass "$CERT_PASS" \
-n "Mi Juego" -i "https://mijuego.example" \
-in build/windows/juego.exe -out build/windows/juego-firmado.exe
mv build/windows/juego-firmado.exe build/windows/juego.exe
rm -f /tmp/cert.pfx
- name: Archivar símbolos
uses: actions/upload-artifact@v4
with:
# Los símbolos se guardan APARTE y no se publican con el juego. Sin
# ellos, los crashes de esta versión no se pueden leer nunca más.
name: simbolos-${{ matrix.plataforma }}-${{ steps.v.outputs.version }}
path: |
build/${{ matrix.plataforma }}/**/*.pdb
build/${{ matrix.plataforma }}/**/*.debug
version.json
retention-days: 365
main ──► dev cada push · equipo · sin firmar · símbolos sí
│
▼ promoción (mismo artefacto)
alpha semanal · testers internos
│
▼ promoción
beta quincenal · público opt-in · notas de versión
│
▼ promoción
production cuando procede · todos · rollout gradual (clase 323)
def promocionar(version, origen, destino):
"""Mueve el MISMO artefacto de canal. No reconstruye: si reconstruyera,
lo que se prueba en beta no sería lo que llega a producción."""
manifiesto = descargar_manifiesto(version, origen)
artefacto = descargar_artefacto(version, origen)
if sha256(artefacto) != manifiesto["archivos"][0]["sha256"]:
raise RuntimeError("el artefacto no coincide con su manifiesto")
publicar(artefacto, manifiesto, canal=destino)
registrar_promocion(version, origen, destino, quien=os.environ["GITHUB_ACTOR"])
- [ ] CI en verde en el commit exacto del tag
- [ ] Presupuestos de rendimiento y de tamaño dentro de límite (clase 321)
- [ ] `SAVE_VERSION` con su migración si cambió (clase 307)
- [ ] Versión de API de mods revisada si cambió (clase 309)
- [ ] Changelog actualizado y legible
- [ ] Manifiesto con checksums generado para cada plataforma
- [ ] Binarios firmados (y notarizados donde aplique)
- [ ] Símbolos archivados y asociados a la versión
- [ ] Feature flags de la versión en su estado correcto (clase 315)
- [ ] Plan de rollback escrito y probado (clase 323)
version.json a tu proyecto y muéstralo en el menú principal y en el crash report.Implementa una cadena de release completa: sellado de versión desde git, matriz de build de al menos dos plataformas, presupuestos de tamaño integrados, manifiesto con checksums, archivado de símbolos, canales con promoción sin reconstruir y lista de comprobación automatizada.
Criterio de aceptación: (a) la build incrusta versión, commit y fecha, y el juego los muestra; (b) construir dos veces el mismo commit produce manifiestos con el mismo número de archivos y el mismo tamaño total, y las diferencias de checksum (si las hay) se explican en el README con su causa concreta; (c) el manifiesto permite verificar cualquier artefacto descargado con un script; (d) los símbolos se archivan con retención de un año y quedan asociados a la versión; (e) promocionar de beta a producción publica el mismo fichero (mismo SHA-256), comprobable; (f) el workflow falla si los presupuestos de tamaño se superan; (g) ningún secreto aparece en los logs del workflow.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| Un crash report llega y no se sabe de qué build es | La versión no está en el binario. Séllala en el build. |
| Los símbolos de la versión que falla ya no existen | No se archivaron. Sube símbolos como artefacto con retención larga. |
| Lo que se probó en beta no es lo que salió | Se reconstruyó al promocionar. Promociona el artefacto, no el código. |
| Windows avisa de "editor desconocido" | Binario sin firmar. Firma con un certificado de código. |
| macOS bloquea el juego | Falta notarización. Es un paso aparte de la firma. |
| Dos builds del mismo commit difieren | Marcas de tiempo o rutas absolutas. SOURCE_DATE_EPOCH y rutas relativas. |
| El tag se creó del commit equivocado | Proceso manual. Construye siempre desde el tag, nunca desde local. |
| Un secreto apareció en el log | Se hizo echo de una variable. Nunca imprimas secretos; usa máscaras. |
| La versión se sube a mano y se olvida | Deriva del tag y falla el build si no coinciden. |
❓ ¿SemVer tiene sentido en un juego? Adaptado, sí. La clave es definir qué significa "romper compatibilidad" en tu caso: saves, mods y protocolo de red. Si un cambio rompe cualquiera de los tres para los usuarios existentes, es un cambio mayor aunque el juego se vea igual.
❓ ¿Merece la pena perseguir builds reproducibles? La reproducibilidad total (bit a bit) es cara y rara vez necesaria en juegos. La reproducibilidad práctica —mismo commit, mismo contenido, mismo tamaño— es barata y te da lo importante: poder afirmar que el binario publicado sale del código que dices.
❓ ¿Puedo publicar sin firmar? Puedes en itch.io y en distribución directa, y tus jugadores verán avisos del sistema. En las tiendas grandes es obligatorio. El certificado cuesta dinero, y es un gasto que hay que prever en el presupuesto de lanzamiento (clase 273).
❓ ¿Cuántos canales necesito? Con dev y production puedes vivir. beta aparece en cuanto tengas jugadores dispuestos a probar cosas a cambio de verlas antes, y es de las herramientas más rentables que existen: te da señal real con público real antes de la publicación.
❓ ¿Dónde guardo los secretos de firma? En los secretos del sistema de CI, nunca en el repositorio ni en el proyecto. Y con acceso restringido: si cualquiera puede lanzar el workflow de release, cualquiera puede firmar con tu certificado.
Clase 321 - Performance regression testing