Automatizar el release: número de build, notas, TestFlight y publicar
Una entrega automatizada no es un guion largo sino cuatro decisiones bien tomadas: de dónde sale el número de compilación para que sea monótono sin coordinación, cómo se generan unas notas de versión que alguien quiera leer, qué parte del camino a la tienda es segura de automatizar y cuál debe seguir siendo un acto humano. Esta lección construye la lane completa y, sobre todo, argumenta dónde poner la frontera.
El objetivo de automatizar la entrega no es ahorrar los quince minutos que cuesta hacerla a mano: es eliminar la varianza. Una entrega manual hecha por la misma persona dos veces seguidas no produce el mismo resultado, porque en algún punto alguien recuerda subir el número, alguien olvida regenerar las notas y alguien elige el perfil equivocado bajo presión. La versión automatizada convierte ese proceso en una función: mismas entradas, misma salida, y un registro de qué se hizo. A partir de ahí, la entrega deja de ser un evento que se planifica para el jueves por la mañana y pasa a ser algo que ocurre cuando el código está listo, que es exactamente el cambio cultural que se busca.
- Elegir una fuente de número de compilación que sea monótona, única y no exija coordinación entre máquinas.
- Generar notas de versión útiles a partir del historial, distinguiendo las internas de las públicas.
- Escribir una lane que archive, firme, suba a TestFlight y etiquete el repositorio con un solo comando.
- Situar de forma razonada la frontera entre lo que se automatiza y lo que sigue requiriendo una decisión humana.
El número de compilación: un hecho, no una opinión
Una aplicación tiene dos números y confundirlos causa la mitad de los problemas. La versión de mercadotecnia, que en el Info.plist aparece como CFBundleShortVersionString, la deciden las personas y comunica algo al usuario. El número de compilación, CFBundleVersion, es un contador interno que solo tiene una obligación: no repetirse nunca dentro de la misma versión de mercadotecnia, y crecer. App Store Connect rechaza una subida con un número ya usado, y ese rechazo llega después de esperar el procesamiento, que es la forma más lenta posible de enterarse.
Las fuentes posibles no son equivalentes y conviene ver por qué.
Contador del sistema de CI
Monótono por construcción y sin coordinación. Su punto débil es que se reinicia si se migra de proveedor o se recrea el proyecto, y entonces hay que aplicar un desplazamiento.
Consultar la última subida
Preguntar a App Store Connect el mayor número existente y sumar uno. Siempre correcto y siempre único, a cambio de una llamada de red que puede fallar y de una carrera si dos entregas coinciden.
Marca de tiempo
Algo como año, mes, día y minutos del día produce un número creciente sin estado alguno. Es la opción más robusta y la menos legible, y basta con no intentar leerla como si fuera un contador.
Contar commits
Tentador y traicionero: no es monótono en presencia de reescrituras de historia, rebases o ramas paralelas, y produce colisiones justo cuando más prisa hay.
# Opcion robusta y sin estado: creciente por construccion.
BUILD=$(date -u +%Y%m%d%H%M)
agvtool new-version -all "$BUILD"
La regla que resuelve la discusión es tratar el número como un hecho generado en el pipeline, no como un dato guardado en el repositorio. En cuanto el número vive en un fichero versionado, cada entrega produce un commit automático, ese commit choca con el trabajo de alguien, y el equipo acaba con un historial lleno de ruido y con conflictos periódicos. Escribirlo en el Info.plist justo antes de archivar, sin confirmarlo nunca, elimina esa clase entera de problemas.
Si la versión pública se escribe a mano en Xcode, tarde o temprano se sube una compilación con la versión anterior. Derivarla de la etiqueta que dispara la entrega, con un formato acordado, hace que el nombre de la etiqueta sea la única declaración de intención y que el resto se deduzca de ella.
Notas de versión: dos públicos distintos
Las notas para probadores internos y las notas para la tienda no son el mismo texto ni tienen el mismo autor. Las primeras responden a una pregunta operativa: qué ha cambiado desde la compilación anterior y qué conviene mirar. Pueden generarse enteras del historial y nadie espera prosa. Las segundas responden a una pregunta comercial y las escribe una persona, porque un usuario no quiere leer identificadores de tickets.
Esa distinción sugiere el diseño correcto: generar automáticamente las internas, y para las públicas dejar que el pipeline recoja un fichero que alguien escribió, fallando de forma explícita si no existe.
flowchart TB tag[Etiqueta de version empujada] --> ver[Derivar version de mercadotecnia] ver --> num[Generar numero de compilacion] num --> notas[Recoger historial desde la etiqueta anterior] notas --> int[Notas internas automaticas para TestFlight] notas --> pub[Buscar fichero de notas publicas] pub --> falta[Si no existe: fallar antes de compilar] int --> arch[Archivar y firmar] arch --> up[Subir a App Store Connect] up --> tf[Distribuir a probadores internos] up --> tag2[Etiquetar el repositorio con la compilacion]
# Historial legible desde la etiqueta anterior, sin ruido de fusiones.
ANTERIOR=$(git describe --tags --abbrev=0 --match 'v*' HEAD^)
git log --no-merges --pretty=format:'- %s (%an)' "$ANTERIOR"..HEAD > notas-internas.txt
echo "Compilacion $BUILD desde $ANTERIOR" | cat - notas-internas.txt > notas.txt
Que las notas salgan del historial impone una consecuencia que conviene aceptar de frente: la calidad de los mensajes de commit deja de ser un asunto estético y pasa a ser una salida visible del proceso. Un equipo que adopta un formato convencional de mensajes obtiene además la posibilidad de deducir el incremento de versión del propio historial, aunque eso último merece cautela, porque delegar en una convención la decisión de si algo es un cambio mayor funciona bien en bibliotecas y bastante peor en aplicaciones de producto, donde la versión pública responde a criterios de mercadotecnia y no de compatibilidad.
Una lane, un comando
Con las dos piezas anteriores resueltas, la entrega completa cabe en una lane legible. Lo importante no es su longitud sino que cada paso sea idempotente y que el fallo ocurra lo antes posible.
platform :ios do
desc "Entrega completa a TestFlight desde una etiqueta"
lane :entregar do
ensure_git_status_clean
version = last_git_tag.delete_prefix("v")
UI.user_error!("Faltan las notas publicas") unless File.exist?("../NOTAS.md")
setup_ci(force: true)
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"],
is_key_content_base64: true
)
match(type: "appstore", readonly: true)
build = latest_testflight_build_number(version: version, initial_build_number: 0) + 1
increment_version_number(version_number: version)
increment_build_number(build_number: build)
build_app(scheme: "MiApp", configuration: "Release",
export_method: "app-store", output_directory: "build")
upload_to_testflight(
distribute_external: false,
changelog: File.read("../notas.txt"),
skip_waiting_for_build_processing: true
)
add_git_tag(tag: "build/#{version}/#{build}")
push_git_tags
end
end
Las tres primeras líneas son las que convierten esto en un proceso fiable. Exigir un árbol limpio impide entregar código que solo existe en la máquina de alguien; derivar la versión de la etiqueta hace que el nombre de la etiqueta sea la declaración de intención; y comprobar que las notas públicas existen antes de gastar diez minutos de compilación es la aplicación literal del principio de fallar pronto.
El indicador que evita esperar el procesamiento merece un comentario. Ese procesamiento puede tardar de minutos a más de una hora y es tiempo de agente facturado sin hacer nada. Terminar el trabajo tras la subida y dejar que la notificación de disponibilidad llegue por otra vía libera la máquina. La excepción es cuando se quiere distribuir a probadores externos en el mismo paso, porque eso sí exige que la compilación esté procesada.
Un pipeline se reintenta. Si la lane etiqueta el repositorio antes de subir y la subida falla, el reintento choca con una etiqueta existente y el fallo real queda enterrado bajo uno cosmético. La regla es sencilla: los efectos irreversibles van al final y en orden de gravedad creciente, y cada uno debe tolerar encontrarse su propio resultado de un intento anterior.
Dónde termina la automatización
Queda la última decisión y es la única que no es técnica. Existe la posibilidad de automatizar el envío a revisión e incluso la publicación automática al aprobarse. Es tentador y casi siempre es un error en aplicaciones de producto, por una razón que no tiene que ver con la confianza en las herramientas: publicar en la tienda es un acto comercial con consecuencias que el pipeline no puede evaluar, como si el equipo de soporte está preparado, si la campaña sale ese día, o si el fin de semana es buen momento para exponer a millones de usuarios a un cambio que nadie podrá revertir hasta el lunes.
La forma madura de trazar esa frontera es automatizar hasta el borde y dejar un único acto humano deliberado. Todo lo que va hasta que la compilación está en App Store Connect, con sus notas y su distribución interna, es mecánico y debe estarlo. El envío a revisión y la publicación quedan como una decisión explícita, apoyada además por la publicación por fases, que reparte el despliegue a lo largo de varios días y permite detenerlo si la telemetría se tuerce.
on:
push:
tags: ['v*.*.*']
jobs:
entregar:
runs-on: macos-15
timeout-minutes: 45
environment: produccion
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: bundle exec fastlane entregar
El fetch-depth completo no es un detalle menor: sin todo el historial no hay etiqueta anterior y las notas salen vacías. Y declarar un entorno protegido añade la aprobación manual justo donde debe estar, en la puerta de la entrega y no en cada paso interno.
El valor real de esta automatización no está en el tiempo ahorrado sino en lo que le hace al comportamiento del equipo, y conviene verlo con precisión porque explica por qué merece la pena el esfuerzo. Cuando entregar cuesta una tarde y sale distinto cada vez, el equipo entrega poco, y como entrega poco cada entrega acumula muchos cambios, y como acumula muchos cambios el riesgo por entrega es alto, lo que justifica entregar aún menos: un bucle de realimentación que se cierra sobre sí mismo y que ninguna dosis de disciplina rompe, porque el problema no es la voluntad sino el coste. Automatizar rompe ese bucle por el único punto donde se puede romper, que es el coste marginal de la enésima entrega. Con una lane fiable, entregar dos veces al día no cuesta el doble que entregar una, cada entrega contiene menos cambios, el fallo se localiza con precisión y la reversión deja de ser una catástrofe para ser una molestia. De ahí se sigue el criterio que ordena la frontera de la automatización: se automatiza todo lo que sea repetible y verificable, precisamente para que la parte que no lo es —la decisión de exponer a los usuarios a un cambio— reciba atención humana de verdad, en lugar de quedar sepultada bajo quince minutos de tareas mecánicas que agotan la concentración justo antes del único momento en que hacía falta.
- Elige tu fuente de número de compilación entre las cuatro opciones y justifica por escrito por qué descartas las otras tres.
- Escribe el guion que genera las notas internas desde la etiqueta anterior y comprueba qué pasa cuando no hay etiqueta previa.
- Monta la lane completa y ejecútala en local contra un grupo interno de TestFlight, midiendo el tiempo total.
- Provoca un fallo deliberado después de la subida y comprueba que un reintento no rompe por una etiqueta duplicada. Corrige el orden si lo hace.
- Configura el disparo por etiqueta con entorno protegido y documenta en cinco líneas cuál es el único acto humano que queda y quién puede realizarlo.