Steven
Steven8 min di lettura

Mettere la release nella CI, due volte

GeekBye v1.8.4 ha spostato le build di release nella CI per macOS e Windows. Ciò che il changelog non dice è che ci avevamo già provato una volta, l'avevamo cancellato un mese dopo per il costo dei runner, e l'abbiamo fatto attecchire solo al secondo tentativo — perché ormai lo script di release funzionava già a mano.

Ingegneria
CI/CD
Desktop
Release di GeekBye
Mettere la release nella CI, due volte

Il changelog della v1.8.4 ha una riga che suona come pura sistemazione: «Le build di release ora arrivano dalla CI, sia per macOS sia per Windows.» Si legge come il tipo di cosa che succede una volta, in modo pulito, e a cui non si ripensa mai. Non è andata così. Il team aveva già messo le release nella CI una volta — e le aveva strappate via. Questa release è il secondo tentativo, e la ragione per cui ha attecchito è la vera lezione di ingegneria, quindi raccontiamola in ordine.

Il primo tentativo, e perché è morto

A ottobre 2025 c'era un release.yml. Veniva innescato al push di un tag — pianti un tag v*.*.* e un runner macOS si avviava, compilava e pubblicava. Faceva la propria preparazione di firma a mano: un passo che decodificava il certificato di firma, creava e sbloccava un keychain temporaneo, importava il cert perché codesign potesse trovarlo, e un passo di pulizia corrispondente alla fine. Funzionava. Era anche solo per macOS — le release di Windows non erano affatto in CI.

Il 4 novembre è stato cancellato. Il messaggio di commit è insolitamente franco sul perché: «remove GitHub Actions release workflow to conserve minutes / macOS runners cost 10x multiplier (100 billed minutes per 10-minute release). Releases will be done locally.» Ecco tutta la storia del primo tentativo in una frase. GitHub fattura il tempo di runner macOS a dieci volte la tariffa di Linux, e una release innescata da tag significa che ogni singolo tag — inclusi quelli usa-e-getta, i re-tag, quelli «ops, dimenticato di alzare la versione» — spende in silenzio cento minuti fatturati. L'automazione che non controlli è automazione che spende denaro mentre dormi. Così le release sono tornate sul Mac di uno sviluppatore per i tre mesi e mezzo successivi.

Dove i problemi difficili sono stati davvero risolti

Ecco la parte che fa funzionare il secondo tentativo, ed è avvenuta interamente fuori dalla CI. Durante quei tre mesi e mezzo di release locali, scripts/release.js — il semplice script Node che uno sviluppatore lancia a mano — ha assorbito ogni problema di packaging che l'app aveva, un fix alla volta:

  • Restore white-label. Questa è una codebase white-label che compila sia GeekBye sia Pavleur da un'unica fonte, il che significa che una release scambia campi di package.json e asset delle icone con quelli del prodotto di destinazione. Due fix hanno insegnato allo script a rimettere tutto a posto dopo, così che compilare Pavleur non lasciasse il tuo albero git sporco con l'identità di Pavleur.
  • Windows dual-arch. Un fix per compilare entrambi gli installer Windows, x64 e arm64, invece di uno solo.
  • Il workaround per Windows non firmato. Le versioni più recenti di electron-builder ignoravano il flag di config che doveva disattivare la firma su Windows, così lo script ha imparato a forzare una build non firmata impostando invece CSC_IDENTITY_AUTO_DISCOVERY=false nell'ambiente — una grinza che trovi solo sbattendoci contro.
  • Autorilevamento della notarizzazione. Lo script ha imparato a guardare il proprio ambiente e a decidere: credenziali di firma presenti? Firma. Presenti anche le credenziali di notarizzazione Apple? Notarizza. Nessuna delle due? Build non firmata. Nessun flag da ricordare; la presenza dei secret è la configurazione.

Niente di tutto ciò è affascinante. È tutto il tipo di cosa che, se ti sorprende dentro un runner di CI, ti costa un'ora di push-aspetta-fallisci-leggi-i-log a tentativo — a fatturazione 10x. Risolto su un Mac davanti a cui sei seduto, ciascuno costa un minuto.

Il secondo tentativo: sessantasei righe

Quando la CI è tornata nella v1.8.4, il workflow era di 66 righe, e la sua qualità distintiva è quanto poco fa. Il commit lo descrive senza fronzoli: «Manual workflow_dispatch trigger that builds both platforms in parallel, reusing existing release.js script. macOS builds are signed and notarized, Windows builds are unsigned.» Ogni decisione di design al suo interno è una cicatrice del primo tentativo:

  • workflow_dispatch, non innescato da tag. Avvii una release cliccando su «Run workflow». Un umano fa da cancello a ogni minuto macOS a pagamento. Il problema di costo che ha ucciso la prima versione si risolve semplicemente non automatizzando l'innesco — l'unico punto in cui l'automazione era attivamente dannosa.
  • Un input product. Il dispatch prende un menu a tendina — geekbye o pavleur — così che lo stesso workflow spedisca l'una o l'altra marca. La cucitura white-label arriva fino al pulsante di release stesso.
  • Due job paralleli. build-mac su macos-latest, build-win su windows-latest, in esecuzione allo stesso tempo. Windows in CI è davvero nuovo qui; il workflow di ottobre non l'ha mai compilato.
  • Nessuna logica di firma nel YAML. È tutto qui il punto. Nessun giocoliere col keychain, nessun passo di import del cert, nessuna pulizia. Il job del Mac lancia node scripts/release.js <product> --publish e il job di Windows lancia lo stesso con --no-sign. Tutto ciò che il primo workflow faceva a mano nel YAML ora vive nello script che già funziona. Il workflow è un orchestratore, non un'implementazione.

Le due piattaforme vogliono cose opposte, e i job lo riflettono onestamente. macOS compila sotto un hardened runtime, firmato e notarizzato tramite il percorso integrato di electron-builder (@electron/notarize), con le credenziali fornite dai secret del repository e autorilevate dallo script. Windows compila installer NSIS non firmati per x64 e arm64. Un workflow, due job, due nozioni completamente diverse di «fatto».

La prova sta in ciò che non è successo

Ecco come sai che la sequenza era giusta: dopo i due ritocchi dello stesso giorno che hanno dato un nome alle esecuzioni e corretto il nome di un secret di token, release.yml non è più stato toccato per quattro mesi. Nessun hotfix per fallimento di firma. Nessuna corsa per un rifiuto di notarizzazione. Nessun panico del tipo «il binario nativo manca sul runner». Per una pipeline di firma-e-notarizzazione del codice — il genere di CI più famigerato per l'instabilità — quattro mesi di silenzio sono qualcosa di quasi inaudito.

Era tranquillo perché il rumore era già avvenuto altrove, dove costa meno. Le battaglie che di solito si combattono in una scheda di log della CI, a fatturazione 10x, un force-push alla volta, si erano combattute sui Mac degli sviluppatori durante l'era locale. La CI non ha dovuto essere il posto dove il packaging veniva debuggato, perché il packaging era già debuggato. Ecco la tesi in una riga: sposta la build fuori dal tuo Mac solo dopo che il tuo Mac ha smesso di sorprenderti.

L'unico accoppiamento che può ancora mordere

Non è del tutto privo di un punto debole, e vale la pena nominarlo perché è sottile. I binari Swift da cui dipende l'app macOS — OCR, cattura schermo, i trascrittori — sono committati in git. Ma la release non spedisce le copie committate; la build le ricompila sul runner. E un cancello check-swift-version.js fa fallire di netto l'intera release se il compilatore non è la versione fissata. A questa release quella versione fissata era Swift 6.2.x, e niente nel workflow la installa — il job si fida semplicemente che lo Swift di default di macos-latest corrisponda. Il giorno in cui GitHub alza l'immagine del suo runner oltre la versione fissata, la release si ferma, non perché ci sia qualcosa che non va nell'app, ma perché la pipeline è silenziosamente accoppiata a un'immagine di macchina che non controlla. È l'unico dettaglio implementativo che questo workflow sottile non ha spinto giù dentro qualcosa che gli appartiene.

L'altra metà della release: payload più piccoli

La release portava una funzionalità non correlata che merita una menzione, perché è un esempio pulito di fix di latenza per sottrazione. L'azione Assist Me mandava al backend più contesto di quanto ne servisse a ogni richiesta. Un commit ha sfoltito due cose: la cronologia di trascrizione allegata è scesa dalle ultime 30 voci alle ultime 15, e i file di contesto del profilo dell'utente — iniettati nel system prompt a ogni chiamata — sono stati limitati di netto a 4,000 caratteri con un marcatore di troncamento. Meno da serializzare, meno da caricare, meno da leggere per il modello prima che inizi a rispondere, che è la metrica che il commit ottimizza: il time-to-first-token. Per amore di onestà: il commit rivendica l'accelerazione ma non la misura — non c'è alcun numero prima/dopo nella cronologia, quindi trattala come uno sfoltimento ben ragionato più che come una vittoria misurata a benchmark.

Tre cose che questa release ci ha insegnato

  1. La CI è un involucro sottile sopra uno script che già funziona. Il workflow da 66 righe non ha logica di firma propria; chiama uno script che tre mesi di release locali avevano già debuggato. Metti il meccanismo in qualcosa che puoi lanciare a mano, e lascia che la CI decida solo quando lanciarlo.
  2. Automatizza il lavoro, non necessariamente l'innesco. Il primo tentativo è morto perché le release innescate da tag spendevano da sole minuti di runner fatturati a 10x. Un workflow_dispatch manuale mantiene l'automazione e rimuove la parte che costava denaro — a volte l'umano nel loop è la funzionalità.
  3. Debugga dove iterare costa poco. Ogni battaglia di packaging risolta sul Mac di uno sviluppatore è una battaglia che non avviene mai in una scheda di log della CI a dieci volte il prezzo. Lascia il Mac per ultimo, non per primo.

Per il capitolo precedente della storia v1, spedire trenta lingue senza rete di sicurezza (v1.8.3); e per tutto l'arco, l'anatomia del pubblicare software fino alla perfezione.