Este documento vive en el repositorio
Es docs/DISTRIBUTION.md.
La web lo incluye tal cual.
Distribución y actualización¶
Cómo llega Didacta a la máquina de alguien, y cómo se mantiene al día.
1. Cómo se reparte¶
Didacta es una aplicación de escritorio para tres sistemas, y el código es abierto. Eso resuelve de golpe la pregunta que antes era la difícil --quién puede instalarla-- y deja solo las dos de verdad: que llegue a la máquina de quien la quiera, y que se mantenga al día sin que nadie se acuerde de nada.
franjfal/didacta (público)
│
«Publish Didacta │ compila macOS/Windows/Linux
Release» ───────────┤ publica el release aquí mismo
│ y dispara el despliegue de la web
│
┌──────────────────┼───────────────────┐
▼ ▼ ▼
la web Releases Didacta, sola
(descarga) (descarga) (actualización)
Un solo repositorio, y eso es un cambio respecto a cómo empezó esto. Antes había dos: el código en uno privado y las versiones en otro, también privado, para poder dar acceso a las segundas sin dar el primero. Con el código abierto esa separación no separa nada, así que se fue, y con ella la GitHub App de publicación, sus dos secrets y la lista de colaboradores que había que mantener.
2. Lo que eso simplificó¶
Conviene dejarlo dicho, porque son cosas que estaban y ya no están:
- no hay comprobación de autorización. Antes, después de entrar en GitHub, la aplicación preguntaba si esa cuenta llegaba al repositorio de versiones y se cerraba si no. Ahora cualquiera puede descargarla y usarla; a lo que hay que tener acceso es al material de cada uno, que es otra cosa y la sigue diciendo GitHub;
- el actualizador no manda ninguna credencial. La API pública basta.
Está explicado en
app/lib/data/release_channel.dart; - la página de descarga es una página de verdad, no el README de un repositorio privado. Ver la sección 7.
3. La versión: una sola fuente, y la sube el ciclo¶
app/pubspec.yaml:
De ahí sale todo: el tag (v1.4.2), el nombre de los artefactos, lo que la
aplicación dice de sí misma, y lo que declara el manifiesto. Ningún otro sitio
escribe un número de versión a mano.
Y ese número no lo escribe nadie: lo sube la publicación. Cada release sube
una parte --la que diga release.yaml-- y el build de uno en uno. Esa línea
deja de ser algo que se edita antes de publicar y pasa a ser el registro de lo
último que se publicó.
Cuánto sube lo dice release.yaml, en la raíz del repositorio:
bump |
También vale | 1.4.2 → | Para |
|---|---|---|---|
major |
grande |
2.0.0 | algo que obliga a aprender otra vez |
minor |
mediana |
1.5.0 | lo de siempre: lo hecho desde la anterior |
patch |
pequeña |
1.4.3 | sólo arreglos |
Tres reglas que lo hacen difícil de estropear:
- Vale para una publicación. El commit final del workflow, el que escribe
la versión en
pubspec.yaml, deja tambiénrelease.yamlotra vez enminor. Una grande que se quedara puesta haría grande la siguiente, y la de después, sin que nadie lo hubiera pedido. - Una errata para, no publica otra cosa. Una clave que no existe
(
bumb:), un valor que no es ninguno de los seis, una línea repetida o sangrada: el workflow se para en su primer paso, antes de compilar, y dice qué línea es. Los tests depackaging/lo leen también en cada pull request. - Es un fichero del repositorio y no un campo del botón, para que los cuatro trabajos, que descargan el mismo commit, lean exactamente lo mismo, y para que la decisión quede en el historial junto a sus notas.
Crecer es añadir una línea a PLAN_KEYS, en release.py: cómo se lee la
clave y qué vale si no está.
$ python3 packaging/release.py plan
bump: minor (mediana) · 1.4.2 → 1.5.0
$ python3 packaging/release.py next
1.5.0
El parche vuelve a cero --1.5.0 y no 1.5.2--: lo que una mediana dice es «otra tanda de cambios», y arrastrar el parche de la anterior no significa nada.
Por qué la mediana si nadie dice nada: lo que cada versión trae es lo que se haya hecho desde la anterior. Un parche afirmaría que sólo se han arreglado cosas, y eso lo tiene que decir alguien a propósito.
Cómo llega ese número a los cuatro trabajos sin pasarse un commit entre
ellos: cada uno ejecuta release.py bump sobre su propia copia nada más
descargarla. La función es determinista, así que los cuatro llegan al mismo
sitio, y el binario lleva dentro el número con el que se publica. El commit se
hace al final, en publicar, cuando el release ya existe -- así un fallo a
mitad de camino, o un ensayo, no gastan un número.
La aplicación no lee una constante del código sino el paquete construido
(Info.plist en macOS, version.json en Windows y Linux), vía
package_info_plus. Una constante puede quedarse atrás de la compilación; el
paquete que se está ejecutando, no. Está en app/lib/data/app_info.dart.
La comparación es semántica, en app/lib/model/app_version.dart. Existe
porque comparar como texto dice que 1.10.0 < 1.9.0, y el síntoma de ese fallo
no es un error sino algo peor: una actualización que nunca se ofrece.
4. Publicar una versión¶
Dos pasos:
- escribir arriba de
CHANGELOG.mdlo que trae la versión. Si todavía no sabes el número,## Próximavale: se lo pone el paso siguiente; python3 packaging/publish.py.
¿Cómo es esta versión?
1) pequeña 0.1.0 → 0.1.1 sólo arreglos
2) mediana 0.1.0 → 0.2.0 lo de siempre: lo hecho desde la anterior ← la de release.yaml
3) grande 0.1.0 → 1.0.0 algo que obliga a aprender otra vez
Después pregunta si se publica o se ensaya, enseña lo que va a hacer y pide un «sí». Antes de escribir nada comprueba lo que suele salir mal:
- que estás en
mainy al día con GitHub --después de cada publicación el workflow deja allí un commit con la versión, y lo ofrece traer--; - que el número no está publicado ya, ni en un borrador a medias;
- que hay notas para él, y si la sección de arriba tiene otro título, le pone el que toca;
- lo que no va a entrar: se compila lo que hay en GitHub, así que un cambio sin commit no va en la versión, y lo dice con la lista delante;
- que no hay otra publicación en marcha.
Si se le dice que sí, deja la parte en release.yaml, hace un commit
«Preparar Didacta X» con eso y el CHANGELOG --sólo con eso--, lo sube y lanza el
workflow con gh. Si gh no está instalado (brew install gh && gh auth
login), hace todo lo demás y abre la página del botón. Al final ofrece
quedarse siguiendo el workflow.
--part, --dry-run y --yes lo dejan contestado de antemano; --yes dice
que sí a las preguntas, pero no se salta ninguna comprobación.
Sin el script también se puede: escribir la sección con el número que diga
python3 packaging/release.py next, poner la parte en release.yaml, y
GitHub → Actions → Publish Didacta Release → Run workflow.
app/pubspec.yaml no se toca. Subirlo a mano se salta un número, porque el
ciclo lo subirá igual la próxima vez.
El workflow (.github/workflows/release.yml) hace, en este orden:
| # | Trabajo | Qué hace |
|---|---|---|
| 1 | comprobar |
Sube el número, exige la sección del CHANGELOG, comprueba que el tag no exista ya |
| 2 | pruebas |
Tests de Python, tests de Dart, flutter analyze --fatal-infos, formato |
| 3 | construir |
macOS, Windows y Linux en paralelo, sin fail-fast, cada uno con el número ya subido |
| 4 | publicar |
Solo si los tres salieron. Publica el release --GitHub crea el tag sobre el commit compilado-- y al final escribe la versión nueva en main y deja release.yaml en minor |
El orden importa. Descubrir que falta la sección del CHANGELOG después de tres compilaciones de quince minutos es tirar media hora por algo que se ve en un segundo.
Sin fail-fast a propósito: si Windows falla, hay que poder ver si Linux y
macOS estaban bien. Cancelar los otros dos esconde justo eso.
Nunca se publica media versión. publicar solo se ejecuta si la matriz
entera salió, y además comprueba que los cuatro artefactos estén antes de tocar
nada.
El release se crea en borrador¶
Y se publica al final, en un solo acto:
crear release (draft) → subir 200 MB de binarios → generar el manifiesto
con los identificadores que GitHub acaba de asignar → subirlo → --draft=false
Mientras se suben los binarios, releases/latest no devuelve nada, y la
aplicación además ignora explícitamente los borradores. Si algo se rompe a la
mitad, lo que queda es un borrador invisible.
Ensayo¶
dry_run compila los tres sistemas y comprueba todo, pero no publica. Sirve
para saber si una versión compila en Windows sin gastar un número: el commit
con la versión nueva sólo lo hace publicar, así que después de un ensayo la
próxima publicación asigna ese mismo número.
Versiones de prueba¶
Con prerelease: true en release.yaml --o python3 packaging/publish.py
--prueba-- lo que sale es una versión de prueba de la siguiente:
1.5.0-rc.1 tras la 1.4.2, y 1.5.0-rc.2 si se publica otra prueba, suba lo
que suba. El release se marca como prerelease, así que releases/latest no
lo devuelve y solo lo reciben las instalaciones que tienen encendido Ajustes
→ Actualizaciones → Versiones de prueba: esas miran la lista de releases y
toman la versión más alta. El manifiesto lleva "prerelease": true.
Las notas de una prueba son las de la sección de arriba del CHANGELOG
--## Próxima-- sin ponerle número: el número es de la final, que se
publica después con prerelease: false como 1.5.0, y es la que llega a
todos. Una final es más alta que sus pruebas, así que quien estaba en la
1.5.0-rc.2 pasa a la 1.5.0 sin hacer nada. Como bump, prerelease vale
para una publicación: plan --reset la vuelve a dejar en false.
Si la publicación sale pero el commit de la versión no¶
El último paso de publicar escribe app/pubspec.yaml en main. El tag no:
lo crea GitHub al publicar el release, sobre el commit que se compiló
($GITHUB_SHA). Crearlo también desde el workflow chocaba con ése, y la 0.1.0
salió publicada con el trabajo marcado en rojo por eso. Si alguien empujó a la rama mientras se compilaba, reintenta poniéndose
detrás; si aun así no puede --una rama protegida, por ejemplo--, el trabajo
falla diciéndolo, y la versión ya está publicada. Entonces hay que subir esa
línea a mano, o la siguiente publicación repetirá el número y se parará en la
comprobación de que el tag no exista.
5. Qué se genera¶
| Sistema | Artefacto | Para qué |
|---|---|---|
| macOS | Didacta-X.Y.Z-macos-universal.dmg |
Instalar a mano |
| macOS | Didacta-X.Y.Z-macos-universal.zip |
El actualizador |
| Windows | Didacta-X.Y.Z-windows-x64.exe |
Las dos cosas |
| Linux | Didacta-X.Y.Z-linux-x64.AppImage |
Las dos cosas |
Y junto a ellos, SHA256SUMS.txt y latest.json.
Un solo artefacto de macOS y no dos por arquitectura. flutter build macos
produce ya un binario universal arm64 + x64 en un solo paso. Publicar un
-arm64 y un -x64 con el mismo contenido sería inventar una elección que
nadie tiene que hacer.
Dos empaquetados de macOS, eso sí. El DMG es lo que espera una persona; el ZIP es lo que necesita el actualizador, porque descomprimir y mover una carpeta son dos órdenes, mientras que montar un DMG, copiar y desmontarlo desde el propio programa que se está sustituyendo son tres formas más de fallar. Es lo que hace Sparkle, por lo mismo.
Windows: Inno Setup, instalación por usuario. PrivilegesRequired=lowest
manda a %LOCALAPPDATA%\Programs\Didacta, donde se puede instalar y actualizar
sin UAC. No se usa MSIX porque exige firma obligatoria para poder instalarse, y
eso dejaría Windows sin versión hasta que haya certificado.
Linux: AppImage. Es un solo fichero, así que actualizarlo es sustituir ese
fichero con un mv --atómico en el mismo sistema de ficheros-- y relanzarlo.
Un .deb lo gobierna el gestor de paquetes, y escribirle los ficheros por
debajo le rompe la base de datos.
6. El manifiesto¶
latest.json, publicado como asset del release:
{
"version": "1.4.2",
"build": 142,
"tag": "v1.4.2",
"publishedAt": "2026-09-15T10:00:00Z",
"releaseNotes": "- Lo nuevo…",
"assets": [
{
"platform": "macos",
"architecture": "universal",
"kind": "update",
"name": "Didacta-1.4.2-macos-universal.zip",
"size": 94371840,
"sha256": "e3b0c442…",
"assetId": 219384756
}
]
}
Tres decisiones, y las tres importan:
Como asset y no como fichero del repositorio ni como página. Hereda la privacidad del release: se descarga solo con un token que tenga acceso. El manifiesto dice dónde están los binarios, así que un manifiesto público sería un inventario público de lo privado.
assetId y no una URL. Es lo que la API pide para descargar con un
Authorization:, que es el único camino que respeta que el repositorio sea
privado. No se guarda ningún browser_download_url ni ningún enlace firmado
que pudiera sobrevivir a que a alguien se le retire el acceso.
kind. Un release lleva dos artefactos de macOS y los dos son «el de
macOS». Sin este campo el actualizador acabaría intentando montar un DMG.
7. La página de descarga¶
Es la web, en web/, publicada en GitHub Pages desde este mismo
repositorio: https://franjfal.github.io/didacta/descargas/.
El bloque de descargas no se escribe a mano ni se guarda en el repositorio: lo
genera packaging/web.py leyendo el release publicado, en el momento de
construir la web.
Que lea el release y no un fichero importa el día que haya que volver atrás:
marcar otra versión como la última es un gh release edit --latest, y con
esto la web se corrige volviendo a desplegarla, sin publicar nada y sin tocar
ningún fichero.
Y si no hay ningún release --el primer día, o un fork-- escribe un bloque que lo dice y explica cómo compilar. Una web que no se puede estrenar hasta que haya binarios es una web que se estrena tarde.
Durante una publicación el manifiesto ya está en el disco del runner, así que también se le puede dar directamente:
El despliegue lo hace .github/workflows/site.yml, que se lanza al empujar a
main, al terminar una publicación y a mano. Genera además las capturas de
pantalla ejecutando la propia aplicación (flutter test
tool/generate_screenshots.dart), así que la documentación enseña siempre la
versión de ahora.
8. Autenticación: OAuth Device Flow¶
Ya existía en app/lib/data/github.dart y no se ha sustituido.
Didacta → «Entrar en GitHub» → enseña ABCD-1234
→ la persona lo escribe en github.com/login/device
→ autoriza allí → Didacta recibe el token
Por qué el device flow y no un redirect con callback:
- no hay secreto que guardar. Un
client_ides público; una aplicación de escritorio no puede esconder unclient_secret, así que un flujo que lo exija es un flujo que se está usando mal; - no hace falta un servidor que atienda la redirección;
- la contraseña no pasa por Didacta. Se teclea en github.com y en ningún otro sitio, que es la única forma honesta de pedirla.
El token va al llavero del sistema (flutter_secure_storage): Keychain en
macOS, Credential Manager en Windows, Secret Service en Linux. Nunca a
SharedPreferences, ni a un JSON, ni a un log. Está en
app/lib/data/repository_access.dart.
El alcance pedido es repo workflow y nada más. Sin delete_repo, sin
admin, sin user. workflow porque GitHub rechaza el envío de un commit
que toca .github/workflows/ si el token no lo tiene, aunque tenga repo: el
ejemplo trae dos workflows, y el CI del material es uno (D81).
El Client ID va escrito en el código¶
Ov23liZqSOY4xMvnXU4Z, en app/lib/main.dart. No es un descuido y no es un
secreto filtrado, y conviene dejarlo dicho porque la pregunta se hace sola al
ver un identificador dentro de un repositorio público:
- un Client ID viaja en la URL de cada autorización, así que ya lo ve en la barra de direcciones cualquiera que entre;
- el secreto de una aplicación de OAuth es el client secret, y el device flow no lo usa. Existe justamente porque una aplicación de escritorio no puede esconder un secreto dentro de un binario que reparte;
- con el Client ID y nada más, lo único que se puede hacer es pedir un código que una persona tiene que autorizar en github.com. No da acceso a nada.
Lo hacen igual gh, VS Code y GitHub Desktop, y por el mismo motivo. Lo que se
gana es lo que decide si alguien llega a usar Didacta: al abrirla por primera
vez hay un botón, y no un campo pidiendo que te crees una aplicación en
GitHub antes de poder empezar. El campo sigue estando, detrás de «Entrar con
otra aplicación de GitHub», para quien monte su propio despliegue --con su
GitHub App o con una OAuth App--; y --dart-define=DIDACTA_GITHUB_CLIENT=… lo
cambia al compilar.
Lo único que un Client ID ajeno permite es montar una aplicación que enseñe «Didacta» en la pantalla de autorización de GitHub. Es así para cualquier aplicación de escritorio y no se arregla escondiéndolo.
La GitHub App¶
Con una GitHub App en lugar de la OAuth App, Didacta llega solo a los
repositorios que cada persona elige en GitHub, y no a todos los suyos con el
permiso repo; y la credencial caduca a las ocho horas y se renueva sola
con otra que dura seis meses, en lugar de valer para siempre hasta que alguien
la revoque. Es lo correcto para una herramienta que reparten los departamentos.
Está registrada: es «Didacta App», de la cuenta franjfal
(github.com/apps/didacta-app, App ID 5104287), y su Client ID,
Iv23liumW0l1zC8E4XxB, es el de salida en didactaAppClientId de
app/lib/data/github.dart. «Didacta» a secas no se pudo: es el nombre de una
cuenta de GitHub. El código es app/lib/model/github_credential.dart,
GitHubAuth.refresh y AuthState.renewIfDue.
Cómo está configurada, por si hay que rehacerla (Settings → Developer settings → GitHub Apps):
- Callback URL: ninguna. Enable Device Flow y Expire user authorization tokens, marcadas; Request user authorization during installation, no.
- Webhook: apagado. Didacta no escucha nada.
- Permisos de repositorio: Contents y Workflows, Read and write --el material, y el CI del material y la web del curso (D81)--; Administration, Read and write --solo para crear el repositorio de ejemplo y los de una asignatura nueva--; Metadata, Read-only, que lo pone GitHub.
- Permisos de cuenta: Email addresses, Read-only, para firmar los commits con el correo de verdad.
- Where can this GitHub App be installed?: Any account.
- Sin client secret: el device flow no lo usa, y en una aplicación de escritorio no sería secreto. GitHub pide una clave privada para que se pueda instalar; Didacta no la usa para nada --actuar como la App y no como la persona es justo lo que no hace--, así que no se guarda en ningún sitio.
Para compilar con otra, --dart-define=DIDACTA_GITHUB_CLIENT= con su
Client ID y DIDACTA_GITHUB_APP_SLUG= con su nombre corto.
La OAuth App de antes ya no está. Hasta la GitHub App se entraba con una
OAuth App, «Didacta» (Ov23liZqSOY4xMvnXU4Z, retiredOAuthClientId). Se
borró antes de publicar la primera versión, porque solo la usaba su autor: no
había sesiones de nadie que conservar. Si ese Client ID sigue guardado en unas
preferencias, no cuenta como elegido y la entrada siguiente es con la App.
Cómo entra cada persona. Con la App: GitHub le pide autorizarla y, la primera vez, instalarla en su cuenta eligiendo a qué repositorios llega (Ajustes → Cuenta y repositorios → Elegir repositorios en GitHub, y Dar acceso a otro al añadir uno). Si tiene abiertos repositorios a los que la App no llega, no se cierran: se dice cuáles y dónde darle acceso, y en cuanto lo tiene, llega sin volver a entrar.
Un Client ID propio se respeta, el de una OAuth App o el de otra GitHub
App: el de la OAuth App de antes es el único que no cuenta como elegido
(replacedClientIds). Una sesión abierta con una OAuth App propia sigue
valiendo: su token no caduca, y se guarda como antes, en texto suelto.
Cómo sabe cuál es cuál. Por el Client ID: los de una GitHub App empiezan
por Iv y los de una OAuth App por Ov. Con una App no se piden permisos al
entrar --son los de la App, los mismos para todos--; lo que se guarda en el
llavero es JSON, con el token, el de renovar, cuándo caduca cada uno y con qué
App se pidió, porque se renueva con la misma. Se renueva quince minutos antes
de caducar, al arrancar si caducó con Didacta cerrada --antes de preguntar a
GitHub quién es, que con el token de ayer sería un 401 y cerraría una sesión
viva-- y al volver a la ventana. Si GitHub dice que la de renovar ya no vale
(seis meses sin abrir Didacta, o la App desautorizada), se sale y se dice por
qué; sin red, se sigue con la que hay y se vuelve a intentar en cinco
minutos.
Un límite de GitHub que hay que saber. Con la App instalada en Solo los repositorios elegidos, un repositorio que Didacta crea --el de ejemplo, el de una asignatura nueva-- puede no quedar en la lista de la App: si después no llega a él, se añade desde Elegir repositorios en GitHub.
La OAuth App de la gente y el token del CI son dos cosas¶
Y no se mezclan nunca:
| Quién la usa | Para qué | Dónde vive | |
|---|---|---|---|
| GitHub App (u OAuth App) | Las personas | Entrar, clonar | Client ID en el código, credencial en el llavero de cada uno |
GITHUB_TOKEN |
El workflow | Crear el release, construir la web | Lo da Actions en cada ejecución |
Reutilizar la OAuth App como credencial de publicación daría a cada persona que entra el permiso de publicar versiones.
9. Quién puede usar Didacta¶
Cualquiera. Es software libre, y descargarla no pide nada.
Entrar en GitHub sí hace falta, pero por otro motivo: el material vive en repositorios, y quién puede leer o escribir en cada uno lo dice GitHub. La aplicación no mantiene ninguna otra lista, ni tiene cuentas propias, ni comprueba ninguna autorización suya.
10. Cómo actualiza¶
app/lib/state/update_service.dart, con checkForUpdates(),
downloadUpdate() e installUpdate().
Cuándo. Cada siete días, en segundo plano, después de que la aplicación esté en pie. Ni al arrancar --retrasaría la primera pantalla por una petición que a nadie le urge-- ni cada vez, que es cómo un aviso útil se convierte en ruido que se cierra sin leer. Y a mano desde Ajustes → Actualizaciones.
Si no hay nada nuevo, no se dice nada. Un «ya estás al día» que nadie ha pedido es una interrupción.
El flujo completo:
1. descargar, con progreso y cancelable
2. comprobar el SHA-256 ← si no cuadra, se borra y no se instala. Nunca.
3. comprobar el tamaño
4. extraer y comprobar que dentro hay una aplicación de verdad
5. comprobar la firma, si la versión instalada está firmada
6. escribir el script de sustitución
── hasta aquí, la instalación que funciona no se ha tocado ──
7. cerrar Didacta
8. el script sustituye, con copia de seguridad
9. relanza
El programa que se actualiza no puede ser el que actualiza. Un ejecutable no
puede reemplazarse mientras corre: en Windows el fichero está bloqueado, y en
macOS borrar el .app bajo los pies de la aplicación la deja sin sus recursos.
Por eso la sustitución la hace un script externo, lanzado con
ProcessStartMode.detached, que espera a que el proceso termine (con tope de
60 segundos) y entonces actúa.
| Sistema | Cómo sustituye |
|---|---|
| macOS | ditto del ZIP; el .app anterior se aparta, no se borra; si falla, vuelve |
| Windows | Ejecuta el instalador con /SILENT; su Restart Manager hace el resto |
| Linux | cp al lado + mv -f sobre el AppImage (atómico); copia de seguridad antes |
Rollback. La versión anterior se aparta y solo desaparece cuando la nueva ya está en su sitio y se ha comprobado que su ejecutable existe. Si algo falla entre medias, el script la devuelve.
Y se comprueba que salió. Antes de cerrar, la aplicación apunta a qué versión se estaba actualizando; al arrancar, compara esa nota con la versión que de verdad está corriendo. Si coincide, lo dice y se puede cerrar el aviso; si no, dice que no se completó y cuál se está ejecutando. Sin esto, una sustitución que falló y se restauró correctamente sería indistinguible de una que salió, y alguien podría quedarse creyendo que tiene una versión que no tiene.
Si no se puede escribir donde está instalada --una Didacta en
/Applications con una cuenta que no es administradora-- se dice antes de
descargar nada. Se comprueba escribiendo de verdad en la carpeta y no mirando
los permisos: en macOS el bit de escritura puede decir que sí y una ACL decir
que no.
Errores. Sin red, GitHub caído, token caducado, token revocado, sin acceso,
release corrupta, checksum incorrecto, descarga interrumpida, sin espacio, sin
permisos, cancelado por la persona: todos están en UpdateProblem y todos
tienen un mensaje en castellano. Ninguno impide usar Didacta.
10.bis Cómo se prueba¶
Tres capas, porque ninguna sola vale:
| Qué | Dónde | Qué coge |
|---|---|---|
| Unitario | app/test/app_version_test.dart, update_manifest_test.dart |
Comparar versiones, leer un manifiesto roto, elegir artefacto |
| Con red falsa | app/test/update_service_test.dart, update_ui_test.dart |
Los códigos de estado de GitHub, los estados del servicio, lo que se ve |
| Con red de verdad | packaging/e2e-macos.sh |
El contrato con GitHub |
La tercera es la que importa y es la que no se puede saltar. Los dos fallos que tenía el workflow al escribirlo solo se veían ejecutándolo:
- un release en borrador no tiene tag --el tag de git se crea al
publicarlo--, así que pedir los assets por
releases/tags/devuelve 404 justo en el momento en que el workflow los necesita; gh release view --json assetsdevuelve el identificador de GraphQL (RA_kwDO…) y la API de descarga pide el numérico. El manifiesto habría salido con identificadores que no descargan nada.
Ninguno de los dos se ve leyendo el código.
Prepara una copia de esa Didacta en una carpeta temporal y actualiza la
copia, así que se puede ejecutar con Didacta abierta sin tocarla. Comprueba,
por este orden: el acceso al repositorio privado, que un token inválido se
rechaza, el manifiesto, que la copia es más vieja que lo publicado, la
descarga por assetId, el SHA-256, que preparar no ha sustituido nada
todavía, la sustitución, que la copia de seguridad se limpió, que arranca, y
que lo que está corriendo dice ser la versión nueva.
Lo único manual que queda es pulsar «Actualizar ahora» en la ventana. Ese
camino --el diálogo, el progreso, «más tarde», los mensajes de error-- está
cubierto por update_ui_test.dart contra el mismo UpdateService; lo que no
se automatiza es el clic.
11. Firma¶
macOS¶
Hoy no hay certificado de Developer ID, y el sistema está hecho para que eso
no impida publicar: packaging/macos/sign.sh avisa y sigue. Un DMG sin firmar
se abre con el aviso de Gatekeeper que le corresponde, que es lo honesto.
Lo que no se hace en ningún caso: quitarle el com.apple.quarantine a
nadie, recomendar spctl --master-disable, ni permitir que el actualizador
instale una versión sin firmar encima de una firmada.
Cuando haya certificado, añadir estos secrets y ya está:
| Secret | Qué es |
|---|---|
MACOS_CERTIFICATE |
El .p12 del Developer ID Application, en base64 |
MACOS_CERTIFICATE_PASSWORD |
Su contraseña |
MACOS_SIGNING_IDENTITY |
Developer ID Application: Nombre (TEAMID) |
MACOS_NOTARY_APPLE_ID |
El Apple ID de la cuenta de desarrollador |
MACOS_NOTARY_TEAM_ID |
El Team ID |
MACOS_NOTARY_PASSWORD |
Una contraseña específica de aplicación |
El orden de la firma es de dentro afuera, con --options runtime (el hardened
runtime, que la notarización exige), y luego notarizar y grapar el ticket
para que se pueda abrir sin conexión. codesign --deep no se usa: Apple lo
desaconseja justo para esto, porque no firma bien los frameworks anidados de
Flutter.
El sandbox está desactivado a propósito y seguirá estándolo: la aplicación
ejecuta git y lee carpetas elegidas en sesiones anteriores, y un sandbox no
permite ninguna de las dos. Eso descarta la Mac App Store, que aquí no importa.
Windows¶
Igual: sin WINDOWS_CERTIFICATE se publica sin firmar, y SmartScreen avisará
la primera vez («Más información» → «Ejecutar de todas formas»).
| Secret | Qué es |
|---|---|
WINDOWS_CERTIFICATE |
El .pfx de code signing, en base64 |
WINDOWS_CERTIFICATE_PASSWORD |
Su contraseña |
Se firma el ejecutable antes de meterlo en el instalador, y el instalador después. Firmar solo el instalador dejaría a SmartScreen avisando al ejecutar el programa.
12. Los secrets¶
En franjfal/didacta → Settings → Secrets and variables → Actions.
Ninguno es imprescindible. El workflow publica con el GITHUB_TOKEN que
Actions le da a cada ejecución: dura lo que dura el trabajo, solo sirve para
este repositorio, y no hay nada que crear, que renovar ni que pueda caducar sin
avisar.
Antes sí hacía falta uno --una GitHub App con Contents: write sobre el
repositorio privado de versiones-- y se fue con él. Es la clase de pieza que
funciona durante un año y falla el día que hay prisa.
Opcionales, los de firma, en las dos tablas de arriba. Sin ellos se publica sin firmar, que es lo que pasa hoy.
Nada más. No hay ningún token en el código, ni en ningún fichero del repositorio, ni se escribe ninguno en un log.
13. Operaciones¶
Dar acceso a alguien¶
No hay nada que dar: la aplicación es pública y se descarga de la web o de Releases.
Lo que sí se da o se quita es el acceso al material, y eso son los permisos de cada repositorio de contenido en GitHub. Si hace falta invalidar el token de alguien ahora mismo, en la OAuth App → Revoke all user tokens.
Rollback¶
Un release publicado no se borra: se marca otro como el último.
A partir de ese momento releases/latest devuelve la 1.4.1, y quien tenga la
1.4.2 instalada no recibirá una oferta de bajar: la comparación es
publicada > instalada, así que nadie se degrada solo. Quien tenga la 1.4.0 sí
recibirá la 1.4.1.
Para que la 1.4.2 no se pueda instalar a nadie más:
Y después publicar una 1.4.3 con lo corregido, que es lo que arregla el problema de verdad.
La web se corrige volviéndola a desplegar --Actions → Publish the documentation site → Run workflow--: lee el release que esté marcado como el último, así que no hay que tocar ningún fichero.
Si una versión rompe la instalación de quien la tenía¶
No debería poder pasar --el paquete se valida entero antes de tocar nada y hay
copia de seguridad-- pero si pasara: el DMG, el .exe y el AppImage de la
versión anterior siguen en Releases, y se instalan encima.
Subir el mínimo soportado¶
Si una versión cambia el formato de lo que hay en disco de forma que no se
pueda saltar desde cualquier versión anterior, se publica con
minimumSupportedVersion. Quien esté por debajo recibe un mensaje que le dice
que descargue el instalador en lugar de una actualización que le dejaría a
medias.
14. Dónde está cada cosa¶
.github/workflows/release.yml El workflow de publicación
.github/workflows/site.yml El de la web y las capturas
web/ La web de documentación
CHANGELOG.md Las notas, escritas a mano
release.yaml Cuánto sube la próxima: grande, mediana o pequeña
packaging/
├── publish.py Publicar desde el terminal, preguntando
├── release.py Versión, plan, notas, checksums, manifiesto
├── web.py El bloque de descargas de la web
├── test_release.py, test_publish.py Sus tests
├── macos/{package,sign,notarize}.sh
├── windows/didacta.iss
└── linux/{package.sh,didacta.desktop,didacta-*.png}
app/lib/
├── model/app_version.dart Comparación semántica
├── model/update_manifest.dart El manifiesto y la elección de artefacto
├── data/app_info.dart Qué versión, qué sistema, qué arquitectura
├── data/release_channel.dart La API de GitHub (sin dart:io)
├── data/update_installer*.dart La instalación por sistema
├── state/update_service.dart La máquina de estados
└── ui/update_section.dart Ajustes, el diálogo y la franja