Llegamos a la última parte. Al terminar la parte 4 tenías una .app firmada, notarizada y con el ticket adjunto. Corre en tu Mac, pero todavía no la puedes compartir. En esta parte cierro el pipeline: empaquetar el binario en un DMG que se descargue como un solo archivo, mover el estado del repo (bump commiteado, tag, push) y publicar en GitHub Releases con el DMG como asset y las notas de la versión sacadas directo del CHANGELOG.md.
Por qué DMG y no ZIP
Se puede distribuir una .app en un ZIP y funciona. Doble clic al ZIP, se descomprime, se ejecuta. Pero un DMG tiene tres cosas que ganan la comparación:
Es un contenedor firmado. Cuando el navegador descarga el DMG, macOS aplica el atributo com.apple.quarantine al DMG, no a la app. Al abrir el DMG y arrastrar la app, la app hereda el estado limpio del DMG firmado —Gatekeeper valida una vez el DMG y ya. Con un ZIP, la app queda con quarantine directo y a veces Finder no libera correctamente el atributo.
Es una unidad estable. Un DMG con la app adentro es un archivo. Se sube como un asset, se descarga como un archivo, se instala como un archivo. Un ZIP fuerza al usuario a saber que hay una carpeta intermedia.
Es el formato que espera el mundo Mac. No es una razón técnica, pero cuenta. Un usuario que descarga una app macOS espera un DMG.
Para el App Store nada de esto aplica —ahí subes un .pkg o dejas que Xcode se encargue. Pero para distribución fuera de la store, DMG es el estándar.
hdiutil create — construcción del DMG
macOS trae hdiutil desde siempre. Es la herramienta para crear, montar, verificar y modificar imágenes de disco. En el Fastfile del repo esta línea construye el DMG:
dmg_path = "#{BUILD_DIR}/#{APP_NAME}-#{version}.dmg"
sh "hdiutil create -volname '#{APP_NAME}' " \
"-srcfolder '../#{app_path}' " \
"-ov -format UDZO '../#{dmg_path}'"
Cada flag:
-volname— el nombre que ve el usuario cuando monta el DMG. En este caso “Pokédex”. Aparece en la barra superior de la ventana del DMG y en el sidebar de Finder.-srcfolder— qué mete el DMG. Le pasas un archivo o carpeta yhdiutillo copia adentro. Aquí pasa el path a la.app.-ov— sobrescribe si el archivo ya existe. Sin esto, si vuelves a correr el lane y el DMG del run anterior sigue ahí,hdiutilse queja y aborta.-format UDZO— formato UDIF Zlib-compressed. Es la variante estándar de DMG comprimido. Ocupa menos que la app pelada y monta rápido.
El ../ en los paths viene de que hdiutil corre desde fastlane/ y todo lo demás vive en la raíz del repo. Lo mismo aplica al output.
Después de esto tienes build/Pokédex-1.0.1.dmg. Cualquiera puede descargarlo, doble clic para montar y arrastrar la app a Applications.
Verificación con spctl sobre el binario
Justo después del DMG, la lane hace una última verificación:
sh "spctl --assess --type execute --verbose '../#{app_path}' 2>&1 || true"
Como conté en la parte 3, esto simula lo que hace Gatekeeper. Si sale accepted con source=Notarized Developer ID, todo bien. El || true es intencional para que el output se imprima aunque spctl salga con exit code no cero por warnings menores. La decisión de continuar la toma quien mira el terminal.
read_changelog_section — un helper para el CHANGELOG.md
El repo tiene un CHANGELOG.md con el formato de Keep a Changelog:
## [Unreleased]
- Trabajo en curso.
## [1.0.0] - 2026-09-05
Primera versión pública.
- Lista de los primeros 60 Pokémon consumiendo la PokeAPI.
- Vista detalle con altura, peso, habilidades y estadísticas base.
- Distribución vía Developer ID + notarización.
Quiero que las notas de la GitHub Release salgan de ahí, no escribirlas dos veces. Para eso el Fastfile define un helper Ruby al final:
def read_changelog_section(version:)
path = "../CHANGELOG.md"
return "Release v#{version}" unless File.exist?(path)
content = File.read(path)
# Match: ## [Unreleased] o ## [1.2.0] hasta el siguiente ##.
if match = content.match(/##\s*\[(?:#{Regexp.escape(version)}|Unreleased)\](.+?)(?=\n##\s|\z)/m)
match[1].strip
else
"Release v#{version}"
end
end
Es una regex que busca la sección ## [Unreleased] o ## [<versión>] y captura todo hasta el siguiente ## (o el fin del archivo). Devuelve ese texto en markdown, listo para ir directo a la descripción del Release.
El fallback (return "Release v#{version}") cubre dos casos: cuando no existe CHANGELOG.md y cuando existe pero no encontró la sección. En cualquiera de los dos, la Release sale con un título mínimo en vez de reventar el lane.
En la lane se usa así:
changelog = read_changelog_section(version: version)
Y changelog se pasa como description: al siguiente paso.
commit_version_bump, tag y push
Antes de crear la Release, el estado del repo se deja consistente:
commit_version_bump(
xcodeproj: PROJECT,
message: "chore(release): v#{version} (build #{bump})"
)
add_git_tag(tag: "v#{version}")
push_to_git_remote(tags: true)
commit_version_bump es más específico de lo que parece. No hace un git commit -am ciego: busca los archivos concretos que Fastlane sabe que cambian al bumpear (.xcodeproj/project.pbxproj, plists) y solo esos van al commit. Si tienes otros cambios sueltos (que a estas alturas no deberías porque ensure_git_status_clean corrió al principio) se queja.
add_git_tag crea un tag v1.0.1 en el commit recién hecho. Es un tag anotado local, todavía no está en remoto.
push_to_git_remote con tags: true sube el commit y el tag al remoto en una sola operación. Si el push falla (por ejemplo porque main avanzó mientras corrías el lane), el lane muere aquí antes de crear el Release, lo cual es correcto —el Release apuntaría a un commit que no está en el remoto.
set_github_release — la publicación
Con el tag en el remoto, la última acción crea el Release y sube el DMG:
set_github_release(
repository_name: GITHUB_REPO,
api_bearer: ENV["GITHUB_TOKEN"],
name: "v#{version}",
tag_name: "v#{version}",
description: changelog,
commitish: "main",
upload_assets: [dmg_path]
)
Bajo el capó, set_github_release hace dos llamadas al REST API de GitHub:
POST /repos/{owner}/{repo}/releasescon el JSON del Release (nombre, tag, descripción). GitHub crea el Release y devuelve unupload_url.POSTalupload_urlcon el binario del DMG comoapplication/octet-stream. GitHub lo asocia al Release como asset descargable.
Los parámetros:
repository_name— formatoowner/repo. En elFastfiledel repo es la constanteGITHUB_REPO = "slekens/pokedex-macos".api_bearer— el PAT que preparaste en la parte 2. Lee deENV["GITHUB_TOKEN"].name— el nombre visible del Release (encabezado grande).tag_name— el tag al que apunta. Debe existir ya (por eso el push va antes).description— el cuerpo del Release. Markdown. Aquí entra lo que devolvióread_changelog_section.commitish— el branch base. Redundante si el tag ya existe, pero es explícito.upload_assets— array de paths a subir como assets. Puedes subir múltiples (.dmg,.zip, checksums), aunque en el repo solo va el DMG.
Cuando el paso termina, en el output ves algo así:
[15:23:47]: 🚀 Pokédex v1.0.1 publicada en GitHub Releases
Y en github.com/slekens/pokedex-macos/releases aparece el Release con el DMG descargable.
El comando completo, una última vez
Todo el pipeline, de arriba a abajo, en un solo comando:
bundle exec fastlane release version:1.0.1
Con eso pasa esto en secuencia:
ensure_git_status_clean+ensure_git_branch(branch: "main").increment_version_numbera 1.0.1,increment_build_numbersube el build en uno.build_mac_appcompila con-configuration Releasey firma con Developer ID.notarizesube a Apple, espera veredicto, adjunta el ticket al binario.hdiutil createempaqueta la.appenbuild/Pokédex-1.0.1.dmg.spctl --assessconfirma que Gatekeeper acepta el binario.commit_version_bumpdeja el cambio de versión commiteado.add_git_tagmarca la versión.push_to_git_remotesube commit y tag al remoto.read_changelog_sectionextrae el markdown delCHANGELOG.md.set_github_releasecrea el Release y sube el DMG.
Los tiempos aproximados que veo en mis corridas: el build en Release toma 1-2 minutos, la notarización 30 segundos a 3 minutos, el DMG 5-10 segundos, el push y el Release son casi instantáneos. Total: entre 3 y 6 minutos desde disparar el comando hasta tener el asset público listo para compartir.
Cerrar la serie
Cinco partes:
- Panorama — Gatekeeper, firma, notarización y adjunción del ticket.
- Setup — certificados, API Key, PAT,
.env. - Notarización a fondo —
codesign, Hardened Runtime, entitlements,notarytool,stapler. - Auto-versionado y lanes — versiones,
qa,release,skip_publish. - Esta — DMG, tag, GitHub Release.
El repo pokedex-macos está en github.com/slekens/pokedex-macos. El Fastfile, el .env.example y el project.yml de los ejemplos son literalmente los del repo. La app en sí (SwiftUI, MVVM sencillo, PokeAPI) queda para una serie separada más adelante —el foco de esta era el pipeline.
Si vas a montar el mismo flujo en otro proyecto, los cuatro archivos que tienes que traducir son: Gemfile, fastlane/Fastfile, fastlane/Appfile y .env.example. Todo lo demás son variantes del mismo esqueleto.