Files
Jonathan David Florez Salomón a37a0bcc0b v1.16.0: la app de macOS por fin se firma de verdad
El build NUNCA firmó la app de macOS. Al no pasarle ninguna identidad, el
bundler de Tauri se saltaba `codesign` entero y el .dmg salía con la firma
ad hoc que deja el enlazador:

  Identifier=labstream_desktop-0a5819ba37119701
  flags=0x20002(adhoc,linker-signed)
  Info.plist=not bound
  Sealed Resources=none

Tres cosas mal, y ninguna avisaba en ningún log:

 · El Entitlements.plist del repo era DECORACIÓN. Los entitlements se
   escriben al firmar; sin firma, el micrófono, la cámara y el JIT nunca
   pidieron nada porque nunca estuvieron pedidos.
 · Ni el Info.plist ni los recursos iban dentro de la firma: se podían
   cambiar sin romperla.
 · El identificador era el del enlazador y no `co.labstream.os`, que es por
   el que macOS recuerda los permisos que le concedes a una app.

AHORA. `APPLE_SIGNING_IDENTITY` se calcula en un paso propio y no en el
`env:` del build, porque un secret que no existe llega como cadena VACÍA y
Tauri lee `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al
importarlo. Solo se exporta lo que tiene valor:

 · con Developer ID → firma real, y notarizado si están las tres claves;
 · sin él → identidad "-", firma AD HOC. No engaña a Gatekeeper, pero sella
   los recursos, ata el Info.plist y APLICA LOS ENTITLEMENTS. En cuanto
   Jonathan añada los secrets, la siguiente etiqueta sale firmada de verdad
   sin tocar un solo archivo.

ENSAYADO sobre una copia de la app instalada, con el mismo comando que va a
correr el CI. Antes y después:

  flags=0x20002(adhoc,linker-signed)  →  flags=0x10002(adhoc,runtime)
  Identifier=labstream_desktop-0a58…  →  Identifier=co.labstream.os
  Info.plist=not bound                →  Info.plist entries=17
  Sealed Resources=none               →  Sealed Resources version=2 rules=13
  (sin entitlements)                  →  allow-jit, audio-input, camera

Y EL ENSAYO ENCONTRÓ UNA TRAMPA que `plutil -lint` no ve: el
Entitlements.plist llevaba `--entitlements` dentro de un comentario XML, y
dos guiones seguidos ahí son XML inválido. plutil lo aceptaba; AMFI, que es
quien lo lee de verdad dentro de codesign, contestaba «Failed to parse
entitlements» y la firma fallaba. Queda avisado en el propio archivo.

De paso se cae `com.apple.security.network.client`: es exclusiva del App
Sandbox y esta app no lo usa, así que no hacía nada.

LA RED QUE FALTABA: un paso nuevo comprueba la firma del .app y TUMBA la
publicación si los entitlements no llegaron. Que esto estuviera roto no lo
dijo nada durante meses; se descubrió a mano persiguiendo otra cosa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:41:13 -05:00

170 líneas
8.5 KiB
YAML

name: Publicar app de escritorio (Windows + macOS)
# Se dispara al publicar una etiqueta de versión (ej. v1.0.0) y también a mano desde la
# pestaña Actions (workflow_dispatch). Construye el .exe (Windows) y el .dmg universal
# (macOS), los firma para el updater y publica todo —incluido latest.json— en un Release.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write # crear/actualizar el Release con los instaladores
jobs:
publish:
strategy:
fail-fast: false
matrix:
include:
# macOS → .dmg universal (Intel + Apple Silicon)
- platform: macos-latest
args: "--target universal-apple-darwin"
# Windows → instalador .exe (NSIS)
- platform: windows-latest
args: ""
runs-on: ${{ matrix.platform }}
steps:
- name: Clonar repo
uses: actions/checkout@v4
- name: Instalar Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Instalar Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Cache de Rust
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Instalar dependencias
run: npm install
- name: Generar iconos (desde app-icon.png)
run: npm run icons
- name: Probar el cliente
run: npm test
# ── FIRMA DE macOS ──────────────────────────────────────────────────────────────────
# Hasta el 2026-09-19 este build NO firmaba la app de macOS: no se le pasaba ninguna
# identidad, así que el bundler de Tauri se saltaba `codesign` entero y el .dmg salía con
# la firma ad hoc que deja el enlazador. Se veía así en la app instalada:
# flags=0x20002(adhoc,linker-signed) · Info.plist=not bound · Sealed Resources=none
# Consecuencias: los entitlements (micrófono, cámara, JIT) NO se aplicaban —el
# Entitlements.plist del repo era decoración—, el Info.plist y los recursos no iban
# sellados, y la app no se podía notarizar de ninguna manera.
#
# `APPLE_SIGNING_IDENTITY` decide qué pasa, y por eso se calcula aquí y no en el `env:`
# del paso de abajo: un secreto que no existe llega como cadena VACÍA, y Tauri
# interpreta `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al importarlo.
# Solo se exporta lo que de verdad tiene valor.
#
# · CON los secretos de Developer ID → firma real + notarizado + grapado.
# · SIN ellos → identidad "-", que es la firma AD HOC: no engaña a Gatekeeper (habrá
# que seguir abriendo con clic derecho), pero SÍ sella los recursos, ata el
# Info.plist y aplica los entitlements. Estrictamente mejor que no firmar.
#
# El hardened runtime no se configura: en Tauri v2 `bundle.macOS.hardenedRuntime` ya
# viene en true, y es lo que hace que los entitlements signifiquen algo.
- name: Preparar la firma de macOS
if: matrix.platform == 'macos-latest'
shell: bash
env:
CERT: ${{ secrets.APPLE_CERTIFICATE }}
CERT_PW: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
NOTARIZA_ID: ${{ secrets.APPLE_ID }}
NOTARIZA_PW: ${{ secrets.APPLE_PASSWORD }}
NOTARIZA_TEAM: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
# Los valores van por heredoc con delimitador: un base64 largo o con saltos rompería
# la forma `CLAVE=valor`. Nada se imprime: los secretos no pasan por el log.
exportar() { printf '%s<<__FIN__\n%s\n__FIN__\n' "$1" "$2" >> "$GITHUB_ENV"; }
if [ -n "${CERT:-}" ] && [ -n "${CERT_PW:-}" ]; then
exportar APPLE_CERTIFICATE "$CERT"
exportar APPLE_CERTIFICATE_PASSWORD "$CERT_PW"
exportar APPLE_SIGNING_IDENTITY "${IDENTITY:-Developer ID Application}"
echo "Firma: Developer ID (certificado del repo)."
else
exportar APPLE_SIGNING_IDENTITY "-"
echo "Firma: AD HOC. Sella recursos y aplica entitlements, pero Gatekeeper seguirá avisando."
echo "Para firma de verdad, añade los secrets APPLE_CERTIFICATE y APPLE_CERTIFICATE_PASSWORD."
fi
# Notarizar solo tiene sentido con Developer ID; las tres claves van juntas o ninguna.
if [ -n "${CERT:-}" ] && [ -n "${NOTARIZA_ID:-}" ] && [ -n "${NOTARIZA_PW:-}" ] && [ -n "${NOTARIZA_TEAM:-}" ]; then
exportar APPLE_ID "$NOTARIZA_ID"
exportar APPLE_PASSWORD "$NOTARIZA_PW"
exportar APPLE_TEAM_ID "$NOTARIZA_TEAM"
echo "Notarizado: activado."
else
echo "Notarizado: omitido (faltan APPLE_ID, APPLE_PASSWORD o APPLE_TEAM_ID, o no hay certificado)."
fi
- name: Construir, firmar y publicar
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Firma del updater: la clave privada y su contraseña van como secrets del repo.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Las APPLE_* las puso el paso de arriba en $GITHUB_ENV, solo si tenían valor.
with:
# En un tag usa su nombre (v1.0.0); en ejecución manual, deriva vX.Y.Z de la versión.
tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }}
releaseName: "Labstream OS Desktop __VERSION__"
releaseBody: "Instaladores de escritorio de Labstream OS.\n\n- Windows: el archivo `.exe` (NSIS).\n- macOS: el archivo `.dmg` universal (Intel + Apple Silicon).\n\nActualización automática activada: las apps ya instaladas se actualizan solas al publicar una versión nueva."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
# ── LA RED QUE FALTABA ──────────────────────────────────────────────────────────────
# Que el build no firmara la app de macOS no lo dijo nada durante meses: el .dmg salía,
# se instalaba y funcionaba. Se descubrió a mano, con `codesign -dv`, persiguiendo otra
# cosa. Este paso hace esa comprobación en cada publicación y TUMBA el build si los
# entitlements no llegaron: es barato y es el único aviso que habría existido.
- name: Comprobar la firma de macOS
if: matrix.platform == 'macos-latest'
shell: bash
run: |
set -euo pipefail
APP=$(find src-tauri/target/universal-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" | head -1)
if [ -z "$APP" ]; then echo "::error::No apareció ningún .app donde se esperaba"; exit 1; fi
echo "== $APP =="
codesign -dv --verbose=4 "$APP" 2>&1 || true
echo "== entitlements =="
codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -convert xml1 -o - - || true
echo "== integridad del bundle =="
codesign --verify --strict --verbose=2 "$APP"
# Gatekeeper: con firma ad hoc esto SIEMPRE dice «rejected», y es esperado. Se
# informa sin tumbar nada; lo que sí es obligatorio es lo de abajo.
echo "== Gatekeeper (informativo) =="
spctl -a -t exec -vv "$APP" 2>&1 || true
echo "== obligatorio: los entitlements tienen que estar =="
if ! codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "com.apple.security.cs.allow-jit"; then
echo "::error::La app salió SIN entitlements. Revisa el paso «Preparar la firma de macOS» y bundle.macOS.entitlements en tauri.conf.json."
exit 1
fi
echo "Entitlements aplicados."
activar:
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publicar solo cuando ambos instaladores estan completos
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -p "require('./package.json').version")
gh release edit "v$VERSION" --draft=false --latest