La última pieza — dónde subir las actualizaciones

En la Parte 1 preparamos el certificado Developer ID y la notarización, y en la Parte 2 la clave de firma de Sparkle. Ya tenemos los medios para firmar la app, notarizarla e incluso verificar la autenticidad de las actualizaciones.

Sin embargo, en el lugar al que apunta el SUFeedURL (https://updates.example.com/appcast.xml) que escribimos en el Info.plist de la app en la Parte 2 todavía no hay nada. En esta última parte alojaremos el feed de actualizaciones que debe ir en ese sitio y terminaremos los ajustes de compilación para concluir toda la configuración inicial.

Igual que en las Partes 1 y 2, todos los nombres y dominios (FocusTimer, example.com, example-dev, etc.) son valores de ejemplo. En la práctica, sustitúyelos por tus propios datos.

Por qué tener un repositorio de actualizaciones aparte

Para que las actualizaciones automáticas funcionen, dos cosas deben estar subidas a internet.

  • appcast.xml — el feed de actualizaciones que indica cuál es la versión más reciente y de dónde descargarla
  • .dmg — el archivo de instalación real de la app

Aquí hay una restricción importante. El Sparkle dentro de la app obtiene estos archivos mediante un simple GET HTTPS sin autenticación. Esto significa que la app en el ordenador del usuario debe poder descargarlos directamente, sin procedimientos como el inicio de sesión.

Muchos desarrolladores mantienen el repositorio fuente principal de la app como privado (private). Pero los archivos de release de un repositorio privado exigen autenticación, así que Sparkle no puede obtenerlos. Por eso, una estructura habitual es la separación de repositorios.

  • Repositorio principal (por ejemplo, FocusTimer) — el código fuente. Puede ser privado.
  • Repositorio de actualizaciones (por ejemplo, FocusTimer-updates) — aloja únicamente el appcast.xml. Tiene que ser público (public) sí o sí.

En este artículo gestionaremos el repositorio de actualizaciones con GitHub Pages, el alojamiento estático gratuito de GitHub.

Paso 1 — Crear el repositorio público de actualizaciones

Creamos un nuevo repositorio en GitHub.

  1. Haz clic en New repository
  2. Nombre: FocusTimer-updates — este nombre se usará en breve en la dirección del feed, así que defínelo con exactitud, incluso en mayúsculas y minúsculas.
  3. Propietario (Owner): tu cuenta o tu organización (ejemplo: example-dev)
  4. Visibilidad: Public — es obligatorio, ya que Sparkle debe obtenerlo sin autenticación.
  5. Marca Add a README file (para comodidad del primer commit)
  6. Create repository

Paso 2 — Activar GitHub Pages

Publicamos el repositorio que acabamos de crear como sitio estático.

  1. Settings del repositorio → menú Pages de la izquierda
  2. Source: Deploy from a branch
  3. Branch: selecciona main / (root)Save
  4. Si tras 1 o 2 minutos puedes acceder a https://example-dev.github.io/FocusTimer-updates/, ha funcionado.

En este punto ya tienes una dirección pública donde subir los archivos de actualización. Pero queda un paso más.

Paso 3 — Conectar un dominio personalizado

Aunque uses tal cual la dirección por defecto de GitHub Pages (example-dev.github.io/...), funciona. Pero si incrustas esa dirección en el SUFeedURL de la app, surgirá un problema cuando el día de mañana tengas que mover el alojamiento de GitHub Pages a otro sitio. La razón es que la app de todos los usuarios ya distribuidos estará mirando la dirección antigua.

La solución es interponer una capa más: un dominio que controlas tú. Si dejas el SUFeedURL con tu propio dominio, como https://updates.example.com/appcast.xml, cuando más adelante muevas el alojamiento bastará con cambiar una sola línea de configuración de DNS para que los usuarios existentes sigan a la nueva ubicación sin más. Esta configuración solo hay que hacerla una vez y queda válida para siempre.

3-1. Añadir el registro DNS

En la pantalla de configuración del proveedor de DNS que gestiona el dominio (example.com), añade el siguiente registro.

CampoValor
TypeCNAME
Nameupdates
Valueexample-dev.github.io
TTL3600 (valor por defecto)

Con esto, el subdominio updates.example.com pasará a apuntar a GitHub Pages.

3-2. Añadir el archivo CNAME al repositorio

En la raíz del repositorio de actualizaciones (FocusTimer-updates), crea un archivo llamado CNAME cuyo contenido sea únicamente una línea con el dominio.

updates.example.com

Haz commit de este archivo y súbelo (push).

3-3. Registrar el dominio en GitHub Pages

En el campo Settings → Pages → Custom domain del repositorio, escribe updates.example.com y pulsa Save. GitHub emite automáticamente un certificado HTTPS y, cuando termina la emisión, marca Enforce HTTPS.

3-4. Comprobación

La propagación del DNS y la emisión del certificado suelen tardar alrededor de 10 minutos. Al cabo de un rato, comprueba con el siguiente comando.

curl -I https://updates.example.com/appcast.xml

Si en la primera línea de la respuesta ves HTTP/2 200, todo está correcto. (Si aún no has subido el appcast.xml, puede salir un 404, pero la conexión del dominio y de HTTPS en sí se puede verificar también por otra vía. Lo importante es que https://updates.example.com devuelva una respuesta.)

Por cierto, lo habitual es subir el archivo de instalación .dmg a GitHub Releases, no a GitHub Pages. Esto se debe a que meter binarios grandes directamente en el repositorio lo hace crecer desmesuradamente. Releases se sirve por una ruta distinta de Pages, así que déjalo tal cual, al margen de la configuración del dominio personalizado anterior.

Paso 4 — Tener el repositorio de actualizaciones en local

Para poder modificar y hacer commit del appcast.xml durante el trabajo de publicación, debes tener también el repositorio de actualizaciones descargado en local. Si lo clonas en una ubicación adecuada dentro de la carpeta del proyecto principal (por ejemplo, release/updates), los scripts de compilación y publicación pueden manejar ambos repositorios desde un solo sitio, lo cual resulta cómodo.

git clone [email protected]:example-dev/FocusTimer-updates.git release/updates

Cuando colocas así un repositorio dentro de otro, añade release/updates/ al .gitignore del repositorio principal para que no interfieran entre sí.

Paso 5 — ExportOptions.plist

Pasemos ahora a la configuración del lado de la compilación. El comando xcodebuild -exportArchive de Xcode lee un archivo de configuración llamado ExportOptions.plist cuando exporta el archivo (archive) como un .app de distribución. El contenido para la distribución directa (canal Developer ID) es el siguiente.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>developer-id</string>
    <key>signingStyle</key>
    <string>automatic</string>
    <key>teamID</key>
    <string>ABCDE12345</string>
</dict>
</plist>
  • methoddeveloper-id. Significa “distribución directa, no a través de la Mac App Store”.
  • signingStyleautomatic. Hace que Xcode elija y use por sí mismo el certificado emitido en la Parte 1.
  • teamID — el Team ID que anotaste en la Parte 1.

Si creas este archivo una vez dentro del proyecto (por ejemplo, release/ExportOptions.plist), lo reutilizarás en todas las publicaciones.

Paso 6 — Revisar la configuración del lado de la app

Por último, repasamos los ajustes que deben estar presentes en el propio proyecto de la app. Si añades Sparkle por primera vez a una app nueva, puedes usar esta lista como checklist.

Info.plist

Son las claves de Sparkle que añadimos en la Parte 2. El Info.plist debe incluir las siguientes claves.

<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>5vT3kQbA9mZ0wR1yX8cD2eF4gH6jK7lN0pS2uV5xW8c=</string>

Vuelve a comprobar que SUFeedURL apunta al dominio personalizado creado en el Paso 3 y que SUPublicEDKey coincide con la clave pública generada en la Parte 2.

Entitlements

Si la app usa el App Sandbox, hace falta un permiso de excepción para que Sparkle pueda comunicarse con servicios internos para instalar la actualización. En el archivo FocusTimer.entitlements entran los siguientes elementos.

<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>

$(PRODUCT_BUNDLE_IDENTIFIER) lo sustituye Xcode automáticamente en la compilación por el identificador de paquete real (com.example.FocusTimer). Para los detalles de cada elemento, consulta la guía oficial de sandboxing de Sparkle.

El App Sandbox no es obligatorio en las apps de distribución directa (el sandbox es un requisito de la Mac App Store). Si tu app no usa el sandbox, no necesitas la excepción mach-lookup de arriba. Eso sí, como las actualizaciones automáticas usan la red, debes mantener activados el permiso network.client y el Hardened Runtime para la notarización.

Build Settings

En la configuración de compilación de Xcode, comprueba lo siguiente.

  • CODE_SIGN_ENTITLEMENTS — la ruta del archivo de entitlements de arriba
  • ENABLE_HARDENED_RUNTIME = YES — requisito imprescindible de la notarización
  • ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES — permite la red para comprobar actualizaciones

Resumen de la serie — configuración inicial completada

La configuración inicial de la distribución directa, repartida en 3 partes, ha terminado por completo. Ahora ya tienes en mano lo siguiente.

  • (Parte 1) Certificado Developer ID Application + credenciales para la notarización
  • (Parte 2) Par de claves de firma EdDSA de Sparkle + copia de seguridad de la clave privada
  • (Parte 3) Repositorio público de actualizaciones conectado a un dominio personalizado + ExportOptions.plist + configuración del lado de la app

Hasta aquí llega la configuración que solo hay que hacer una vez. Estas tareas no hace falta repetirlas cada vez que publiques una nueva versión.

A partir de ahora, el flujo para distribuir una nueva versión se repite de forma similar cada vez — compilar el archive → exportarlo con ExportOptions.plist → firmar con el certificado Developer ID → notarizar con notarytool → generar el .dmg → firmar con la clave de Sparkle → actualizar el appcast.xml → subir el .dmg a un GitHub Release. Este proceso repetitivo se puede automatizar en su mayor parte con un único script, y ese será el tema de otro artículo aparte.

De todo eso, crear el .dmg lleva enredados elementos de diseño como la imagen de fondo y la disposición de los iconos, así que se trata en detalle en una serie aparte, Diseñar el DMG de distribución de tu app de macOS.

La distribución directa parece abrumadora al principio porque hay muchas cosas que configurar, pero la clave es que “una vez que lo tienes preparado, se reutiliza una y otra vez”. A cambio de renunciar a parte de la comodidad de la App Store, pasas a controlar tú mismo todo el proceso de distribución.

Recursos de referencia