macOSFastlaneVersionadoSemVerXcode

CI/CD para macOS con Fastlane (parte 4): auto-versionado y lanes QA vs Release

Con los certificados en el Keychain y la notarización explicada, toca meterse a lo que hace propiamente Fastlane. En esta parte desmenuzo el Fastfile del repo pieza por pieza: cómo se manejan las versiones, qué separa la lane qa de la release, y cómo el flag skip_publish te deja ensayar el pipeline completo sin publicar todavía.

Dos versiones que macOS distingue

Toda app de Apple carga dos números distintos:

  • MARKETING_VERSION (CFBundleShortVersionString) — la versión “de marketing”, visible en About, en el App Store, en la web. Se sigue SemVer: 1.0.1, 2.0.0, etc.
  • CURRENT_PROJECT_VERSION (CFBundleVersion) — el build number, un entero que sube monótonamente en cada compilación distribuible. 1, 2, 3, 123

macOS los usa distinto. Cuando el usuario ve “Pokédex 1.0.1”, eso es marketing. El build number importa cuando publicas dos binarios distintos de la misma versión marketing (por ejemplo un hotfix): macOS elige el que tenga el build más alto. Y en el App Store la regla es aún más estricta —cada subida a ASC exige un build number nuevo.

En el project.yml del repo los valores base son:

settings:
  base:
    MARKETING_VERSION: "1.0.0"
    CURRENT_PROJECT_VERSION: "1"

Esos son los defaults iniciales. A partir de ahí, Fastlane los mueve.

Cómo Fastlane los toca

Dos acciones se encargan:

increment_version_number(xcodeproj: PROJECT, version_number: version)
bump = increment_build_number(xcodeproj: PROJECT)

increment_version_number setea la versión marketing al valor que pasas. Por eso la lane release la recibe por parámetro (options[:version]): la marketing es una decisión tuya, no del pipeline.

increment_build_number incrementa el build number en uno. No recibe valor —lee el actual del .xcodeproj y lo sube. Devuelve el nuevo número, que se guarda en bump para poder loggearlo o usarlo en el commit.

Después de correr, Fastlane escribe los nuevos valores directamente al project.pbxproj. Eso significa que el bump queda como cambio en git —de ahí que la lane release haga commit_version_bump más adelante para dejarlo commiteado.

La lane qa — rápida, firmada, sin notarizar

La lane qa del repo, sin cortes:

lane :qa do
  ensure_git_status_clean

  bump = increment_build_number(xcodeproj: PROJECT)
  UI.message "Nuevo build number: #{bump}"

  build_mac_app(
    project:          PROJECT,
    scheme:           SCHEME,
    configuration:    "Release",
    output_directory: BUILD_DIR,
    output_name:      "#{APP_NAME}-QA",
    export_method:    "developer-id",
    clean:            true,
    skip_package_pkg: true
  )

  UI.success "✅ QA build listo en #{BUILD_DIR}/#{APP_NAME}-QA.app"
  UI.important "Este build está FIRMADO pero NO notarizado. Solo úsalo internamente."
end

Cuatro cosas para notar:

ensure_git_status_clean — corta el lane si hay cambios sin commitear. Fastlane va a tocar el .xcodeproj (el build number), y si mezclas ese cambio con otros que estabas editando, el commit del bump se te ensucia. Aborta al principio, no a la mitad.

Solo bumpea build number, no marketing — porque QA no cambia la versión pública. Estás haciendo builds internas de la misma versión.

export_method: "developer-id" — le dice a build_mac_app que firme con Developer ID Application (no con distribution del App Store). Esto es lo que amarra la firma que discutimos en la parte 3.

No hay notarize — deliberado. Notarizar tarda entre 30 segundos y 5 minutos. Para builds internos que solo van a correr en Macs donde el certificado ya está en el Keychain, no vale la pena esperar. El build sale firmado, y para ti como desarrollador con el cert instalado, corre sin problema. Si otro desarrollador de tu equipo lo quiere probar, la primera vez tendrá que hacer click derecho → Abrir. Es una fricción aceptable para builds internas.

La lane release — el pipeline completo

La lane release es más larga porque hace todo. La copio con anotaciones:

lane :release do |options|
  version = options[:version] || UI.user_error!(
    "Falta version:. Ejemplo: fastlane release version:1.0.1"
  )
  skip_publish = options[:skip_publish] || false

  ensure_git_status_clean
  ensure_git_branch(branch: "main")

Empieza más estricto: además de git limpio, exige que estés en main. Un release oficial no sale desde una rama de feature. La disciplina la impone el Fastfile, no la memoria.

  # 1. Version marketing por parámetro. Build number auto-incrementa.
  increment_version_number(xcodeproj: PROJECT, version_number: version)
  bump = increment_build_number(xcodeproj: PROJECT)

Marketing = lo que dijiste en el comando. Build = auto-incremento. Este orden importa: si algo se cae después de esto, tu project.pbxproj queda modificado localmente pero el commit todavía no se hace, así que un git checkout project.pbxproj te regresa a limpio.

  # 2. Build firmado con Developer ID Application.
  build_mac_app(
    project:          PROJECT,
    scheme:           SCHEME,
    configuration:    "Release",
    output_directory: BUILD_DIR,
    output_name:      APP_NAME,
    export_method:    "developer-id",
    clean:            true,
    skip_package_pkg: true
  )

  app_path = "#{BUILD_DIR}/#{APP_NAME}.app"

Idéntico a la lane qa excepto el output_name. Aquí el binario se llama “Pokédex.app”, no “Pokédex-QA.app”. Es la única diferencia efectiva en la fase de build.

  # 3. Notarización con notarytool (API Key limpia, sin passwords).
  notarize(
    package:      app_path,
    bundle_id:    BUNDLE_ID,
    api_key_path: ENV["APP_STORE_CONNECT_API_KEY_PATH"],
    print_log:    true,
    verbose:      true
  )

El paso que la lane qa no tiene. La parte 3 lo cubrió en detalle. notarize de Fastlane también hace el stapler staple automáticamente si Apple aprueba.

Sigue el DMG, la verificación con spctl, y luego el punto de decisión:

  if skip_publish
    UI.important "skip_publish=true → me detengo antes de tag y GitHub Release."
    UI.success "DMG listo en #{dmg_path}. Publica manualmente cuando quieras."
    next
  end

skip_publish — el modo “ensayo”

Este flag es el que más uso durante el desarrollo. Cuando lo activas:

bundle exec fastlane release version:1.0.1 skip_publish:true

Todo el pipeline corre —build, firma, notarización, adjuntar el ticket, DMG, spctl— pero el lane termina antes del commit del bump, del tag y del GitHub Release. Al terminar tienes un DMG listo, notarizado, firmado, en build/Pokédex-1.0.1.dmg. Puedes:

  • Instalarlo en un Mac limpio y validar que abre sin fricción.
  • Compartirlo con testers antes de decidir si publicas.
  • Revisar el output de notarize a ver si hay warnings que quieras atender.

Si algo sale mal en el pipeline, el .xcodeproj está modificado localmente (el bump de versión y build), pero no hay commit ni tag ni Release. Un git checkout PokedexMac.xcodeproj y vuelves a limpio para reintentar.

Cuando estás listo de verdad, corres sin el flag:

bundle exec fastlane release version:1.0.1

Y ahora sí, sigue la parte que publica.

La parte que publica

Después del if de skip_publish, la lane termina con:

  commit_version_bump(
    xcodeproj: PROJECT,
    message:   "chore(release): v#{version} (build #{bump})"
  )
  add_git_tag(tag: "v#{version}")
  push_to_git_remote(tags: true)

  changelog = read_changelog_section(version: version)

  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]
  )

Esos son los pasos 6 y 7. Los desmenuzo en la parte 5, que es la última: cómo se construye el DMG con hdiutil, cómo read_changelog_section extrae la sección [Unreleased] del CHANGELOG.md, y qué hace exactamente set_github_release.

Un patrón detrás de las dos lanes

Si te fijas en la estructura general, hay una simetría deliberada:

  • qa es un subconjunto de release.
  • Ambas empiezan con ensure_git_status_clean.
  • Ambas hacen build_mac_app con export_method: "developer-id".
  • La diferencia principal es: release notariza, empaqueta, publica.

Eso hace que la lane qa sea la forma más rápida de detectar problemas del pipeline antes de invertir tiempo en notarizar. Si la app tiene un problema de firma o de entitlements va a fallar también en qa, pero sin esperar los 3 minutos que se lleva Apple para responder.