Pular para o conteúdo
Electron

GitHub Actions: compilando Electron para 3 sistemas

Paloma Macetko
Ilustração colorida de três corujas artesãs martelando cristais em forma de janela, maçã e pinguim, com um unicórnio de crina luminosa apontando uma varinha marcada "version" e três esteiras de luz levando caixas de instalador até um baú aberto

Olá meus Unicórnios! 🦄✨

Lá em 2023 eu escrevi aqui um artigo sobre compilar um app Electron pelo GitHub Actions. Era curtinho, funcionava, e resolvia o problema principal: eu uso Windows e não tenho um Mac — mas o runner do GitHub tem. 🍎

Criando uma Action para compilar uma aplicação feita no ElectronJs utilizando GitHubO artigo original, de 2023 — a versão curta com dois jobs (Windows e macOS)blog.palomamacetko.com.br

Três anos depois eu voltei ao mesmo problema, só que num projeto de verdade: o Caderno Mágico, um monorepo com quatro workspaces npm (shared, backend, frontend e electron), banco SQLite nativo, e a necessidade de gerar instalador para Windows, macOS e Linux a cada tag.

Sabe quando você acha que vai ser meia hora de trabalho? 😅 Pois é. O workflow final tem 3 jobs e ficou bonito. Mas ele chegou nesse estado depois de oito commits de correção, e cada um deles é um erro que eu só descobri com um build vermelho na cara.

Este artigo é o workflow completo e a lista dos tombos. Porque o YAML pronto você acha em qualquer lugar — o que ninguém escreve é por que cada linha estranha está ali. 🙃

Se você prefere o código antes da explicação, está tudo num repositório pronto para clonar — com o workflow inteiro e cada armadilha comentada no lugar onde ela mora:

cmacetko/Exemplos_ElectronActionsO app de exemplo completo: Express + SQLite + Electron, com o workflow dos três sistemasgithub.com

🏗️ O esqueleto do workflow

Comecemos pelo topo do arquivo, que é curto e já tem uma linha que quebra build de gente distraída:

name: Release Desktop (Windows · macOS · Linux)

on:
  push:
    tags:
      - "v*"

permissions:
  contents: write # necessário para criar o Release e anexar assets

O gatilho por tag é o de sempre: git tag v0.4.0 && git push --tags e a fábrica liga. Push normal na master não compila nada — e isso é proposital, porque compilar três sistemas custa minuto de runner.

Agora o permissions: contents: write. Ele parece burocracia, mas sem ele o passo final falha com 403 na hora de criar o Release. O GITHUB_TOKEN que o Actions injeta é somente leitura por padrão em repositórios criados nos últimos anos. Vale decorar: se o build passou e só o upload falhou com 403, é essa linha que está faltando. 😤

Dentro de cada job, as actions vêm com a major fixada — actions/checkout@v5 e actions/setup-node@v5. Parece detalhe, mas eu já usei setup-node@master por preguiça e me arrependi: apontar para o branch de uma Action significa que a sua build muda sozinha quando o autor faz um push, e você descobre isso num build vermelho de sexta à noite. 🙃 Fixe a major sempre.

📦 Armadilha nº 1: o npm ci que não roda fora do Windows

Essa foi a que mais me custou tempo, e é a mais fácil de acontecer com você. Se você só for ler um pedaço deste artigo, leia este. 🙏

Meu package-lock.json foi gerado no Windows. O job do Windows passava lindamente. O do macOS e o do Linux morriam assim:

Error: Cannot find module @rollup/rollup-darwin-arm64
Error: Cannot find module @rollup/rollup-linux-x64-gnu

Minha primeira reação foi a errada: achei que era cache do setup-node. Não era. 😳

O que acontece é o seguinte. O Rollup e o esbuild distribuem o binário compilado em pacotes opcionais por plataforma — um pacote para win32-x64, outro para darwin-arm64, outro para linux-x64-gnu. Quando você roda npm install no Windows, o lockfile materializa só o do Windows. E o npm ci é, por design, estrito: ele instala exatamente o que está no lock e não sai buscando o que falta.

Resultado: no runner do Mac, o lock manda instalar o binário do Windows, o Rollup procura o do Mac, não acha, e o build morre. É o bug npm/cli#4828, que já tem anos.

A tentativa que não funcionou

Minha primeira correção foi acrescentar um npm install --no-save depois do npm ci, para "completar" os opcionais que faltavam. Parecia esperto. Não resolveu — o --no-save respeita a árvore que o lock já fixou e continuava sem materializar os nós de outra plataforma.

Também tentei gerar um lockfile multiplataforma a partir do Windows, com as flags que a documentação sugere:

npm install --os=darwin --cpu=arm64 --include=optional

Nesse monorepo com workspaces, não funcionou. As flags não materializaram os nós das outras plataformas no lock. Passei um bom tempo aqui antes de aceitar.

O que funcionou

A saída é a que o próprio npm recomenda para esse bug, e ela é desconfortável de escrever: jogar o lockfile fora dentro do CI e deixar cada runner resolver os opcionais da sua plataforma.

# Workaround npm/cli#4828 / #8320: o package-lock.json não materializa os
# binários opcionais (Rollup/esbuild) de outras plataformas além daquela em
# que foi gerado, então `npm ci` falha no macOS/Linux. Removemos o lock e
# usamos `npm install` para o runner resolver os opcionais da sua plataforma.
- name: Instalar dependências (resolve opcionais da plataforma)
  shell: bash
  run: |
    rm -f package-lock.json
    npm install --no-audit --no-fund

Eu sei, dói. 😖 Abrir mão do lockfile é abrir mão da reprodutibilidade exata do build, que é justamente o motivo de o lockfile existir. Mas repare que o rm acontece dentro do runner, num checkout descartável: o arquivo continua versionado no repositório e continua valendo para quem desenvolve na máquina. O que se perde é a garantia dentro do CI; o que se ganha é um CI que roda nos três sistemas.

Se o seu projeto não usa Rollup nem esbuild (build só com tsc, por exemplo), não copie isso — mantenha o npm ci. No mesmo commit em que fiz essa mudança, deixei os Dockerfiles do backend intocados exatamente por isso: eles compilam com tsc e não sofrem do bug.

🏷️ A versão vem da tag, não do package.json

Aqui vai uma decisão pequena que evita uma classe inteira de erro humano. A versão do app não é lida do package.json: ela é derivada da tag e escrita no package.json antes de empacotar.

- name: Derivar versão da tag
  id: ver
  shell: bash
  run: |
    VERSION="${GITHUB_REF_NAME#v}"
    echo "version=$VERSION" >> "$GITHUB_OUTPUT"

Aquele ${GITHUB_REF_NAME#v} é expansão de shell: tira o prefixo v. A tag v0.4.0 vira a versão 0.4.0. Simples e sem sed.

Com a versão em mãos, gravamos no electron/package.json, que é o arquivo que o electron-builder lê:

- name: Sincronizar versão no electron/package.json
  shell: bash
  run: |
    node -e "const fs=require('fs');const p='electron/package.json';const j=JSON.parse(fs.readFileSync(p,'utf8'));j.version='${{ steps.ver.outputs.version }}';fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n');"

Por que isso importa tanto? Porque o auto-update compara a versão que está dentro do app instalado com a versão anunciada no latest.yml. Se você criar a tag v0.4.1 mas esquecer de subir o número no package.json, o instalador sai com 0.4.0 escrito dentro dele — e o app atualizado continua achando que precisa atualizar, num loop infinito de update que instala a mesma versão para sempre. 🤯

Derivando da tag, esse esquecimento deixa de ser possível: existe uma fonte da verdade, e é o nome da tag que você digitou.

🔐 Armadilha nº 2: secret do CI não existe na máquina do usuário

Esta é minha favorita, porque o erro é silencioso — não há build vermelho. O app compila, instala, abre… e não conecta.

Meu config.ts lia as URLs do backend assim, o que parece perfeitamente razoável:

const backendUrl = process.env.CM_BACKEND_URL || 'http://localhost:4000';

E no workflow eu passava o secret com env: no passo do build. Tudo certo, não?

Não. 😳 O process.env é lido quando o app roda — e ele roda no computador do usuário, três dias depois, onde nunca existiu um secret do GitHub. A variável chega vazia, o || faz seu trabalho, e o app empacotado sai apontando para localhost:4000. Cada instalação distribuída ia procurar um backend na máquina de quem instalou.

A correção é gravar o valor dentro do código-fonte antes do tsc rodar. Existe um arquivo com placeholders, versionado assim:

/**
 * Config injetada no momento do BUILD (CI). Estes valores são sobrescritos pelo
 * workflow de release a partir dos secrets CM_BACKEND_URL / CM_WEB_URL.
 * NÃO leia process.env aqui: env vars do CI não existem em runtime na máquina
 * do usuário — por isso precisam ser fixadas neste arquivo no build.
 */
export const BUILD_BACKEND_URL = "";
export const BUILD_WEB_URL = "";

E um passo do workflow reescreve as strings antes de compilar:

- name: Bake URLs no buildConfig (a partir dos secrets)
  shell: bash
  env:
    CM_BACKEND_URL: ${{ secrets.CM_BACKEND_URL }}
    CM_WEB_URL: ${{ secrets.CM_WEB_URL }}
  run: |
    node -e "const fs=require('fs');const f='electron/src/buildConfig.ts';const b=process.env.CM_BACKEND_URL||'';const w=process.env.CM_WEB_URL||'';if(!b||!w){console.error('CM_BACKEND_URL/CM_WEB_URL ausentes nos secrets');process.exit(1);}const src=fs.readFileSync(f,'utf8').replace(/export const BUILD_BACKEND_URL = \".*\";/, 'export const BUILD_BACKEND_URL = '+JSON.stringify(b)+';').replace(/export const BUILD_WEB_URL = \".*\";/, 'export const BUILD_WEB_URL = '+JSON.stringify(w)+';');fs.writeFileSync(f,src);console.log('buildConfig: backend='+b+' web='+w);"

Duas coisas para reparar nesse node -e, porque as duas são deliberadas:

O process.exit(1) quando o secret falta. Sem essa guarda, um secret não configurado geraria um app com string vazia e o bug voltaria calado. É muito melhor o build ficar vermelho no minuto 2 do que você descobrir pelo suporte, semanas depois. Falhe cedo, falhe alto. 📢

O JSON.stringify(b) em vez de '"' + b + '"'. É ele que escapa aspas e barras dentro do valor. Uma URL não costuma ter aspas, mas o dia em que alguém colar um secret com um caractere esquisito, a diferença entre as duas formas é um arquivo .ts que não compila mais.

A ordem de resolução final ficou: envbuildConfiglocalhost. Em desenvolvimento, o buildConfig está vazio e cai no localhost, que é o que eu quero na minha máquina.

🧩 Armadilha nº 3: o hook predist que não dispara

Essa é uma pegadinha do npm, não do Actions — mas ela só apareceu no CI, e o erro era ótimo de tão enganador:

Application entry file "dist/main.js" does not exist

No meu electron/package.json eu tinha isto:

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "predist": "npm run build:bundle --workspace @caderno/frontend && npm run build",
    "dist": "electron-builder --win",
    "dist:win": "electron-builder --win --publish never",
    "dist:mac": "electron-builder --mac --publish never",
    "dist:linux": "electron-builder --linux --publish never"
  }
}

Localmente eu sempre rodava npm run dist. O npm via o predist, compilava o TypeScript, e tudo funcionava. No workflow eu chamei npm run dist:win — porque preciso escolher a plataforma — e o build quebrou.

O motivo é bobo e exato: o hook pre casa pelo nome completo do script. predist roda antes de dist, e só de dist. Para dist:win o npm procuraria um predist:win, que não existe. Ninguém compilou o TypeScript, o dist/main.js nunca apareceu, e o electron-builder reclamou de um arquivo que eu jurava que era gerado.

Consertei dos dois lados. No workflow, o build ficou explícito e na ordem das dependências do monorepo:

- name: Build shared + frontend + electron
  shell: bash
  run: |
    npm run build:shared
    npm run build:frontend
    npm run build --workspace @caderno/electron

E no package.json, cada variante passou a chamar o que precisa por conta própria:

"dist:win": "npm run rebuild:native && npx --no-install electron-builder --win --config.npmRebuild=false --publish never"

Prefira sempre o explícito no CI. Hook implícito é ótimo para o seu conforto na máquina local e péssimo para entender um log de build. 🙃

⚙️ Módulo nativo: o rebuild:native

O Caderno Mágico usa better-sqlite3, que é um módulo nativo — tem C++ compilado dentro. E aqui mora uma confusão que pega muita gente: o binário que o npm instalou foi compilado para o Node, e o Electron embute uma versão de V8 diferente. Carregar um contra o outro dá aquele erro clássico do NODE_MODULE_VERSION.

"rebuild:native": "npx electron-rebuild -v 31.7.7 -f -w better-sqlite3"

O electron-rebuild recompila o módulo contra os headers do Electron. E repare no --config.npmRebuild=false que aparece nos scripts de dist: ele desliga o rebuild automático do electron-builder, porque nós já fizemos o rebuild um passo antes. Deixar os dois ligados é pagar a compilação duas vezes por build, em três sistemas. 🕐

Duas outras chaves da config merecem menção, porque são as que fazem o app funcionar depois de instalado:

"asarUnpack": [
  "node_modules/better-sqlite3/**"
],
"extraResources": [
  { "from": "../frontend/dist", "to": "frontend" }
]

O asarUnpack existe porque o Electron empacota tudo num arquivo .asar, e o sistema operacional não consegue carregar um .node de dentro de um .asar — ele precisa de um arquivo real no disco. Sem essa linha, o app abre e morre no primeiro acesso ao banco. O extraResources leva o build do frontend (que mora em outro workspace) para dentro do pacote.

🖥️ Os três jobs, lado a lado

Os três jobs são quase idênticos — a diferença real cabe em três linhas por job. Vale ver as três juntas:

Jobruns-onComandoSai
Windowswindows-latestdist:win.exe (NSIS) + .blockmap + latest.yml
macOSmacos-latestdist:mac.dmg
Linuxubuntu-latestdist:linux.AppImage + latest-linux.yml

Os três rodam em paralelo, porque não declarei needs: entre eles. Isso é de graça e é o motivo de o release inteiro sair em pouco mais que o tempo do job mais lento.

Uma decisão que já me perguntaram: por que triplicar o YAML em vez de usar strategy: matrix? Porque os jobs não são iguais o suficiente. O do Mac precisa de um passo extra para normalizar o nome do .dmg; o do Windows e o do Linux sobem arquivos de metadados diferentes; e num matrix isso vira uma coleção de if: matrix.os == '...' espalhados que, na minha experiência, é mais difícil de ler que a repetição honesta. Se um dia os três convergirem, o matrix entra.

O passo a mais do macOS

O electron-builder nomeia o DMG a partir do productName, que no meu caso tem espaço: Caderno Mágico. Espaço em nome de arquivo de download é briga garantida. Então normalizo:

- name: Normalizar nome do asset
  id: asset
  run: |
    VERSION="${{ steps.ver.outputs.version }}"
    SRC="$(find electron/out -maxdepth 1 -iname '*.dmg' | head -n1)"
    DEST="electron/out/Caderno-Magico-${VERSION}.dmg"
    [ "$SRC" != "$DEST" ] && cp "$SRC" "$DEST"
    echo "path=$DEST" >> "$GITHUB_OUTPUT"

Repare que uso cp e não mv, e que o teste [ "$SRC" != "$DEST" ] evita copiar um arquivo em cima de si mesmo caso os nomes já coincidam — o que faria o comando falhar e derrubar o job.

E agora a parte importante: eu normalizo o nome do DMG, mas não toco no nome do .exe nem do .AppImage. Isso não é inconsistência, é o próximo tópico.

🔄 Armadilha nº 4: renomear o instalador quebra o auto-update

O auto-update do Electron (o electron-updater) funciona assim: ao abrir, o app baixa um arquivo latest.yml do seu servidor de releases. Esse YAML é gerado pelo próprio electron-builder e diz, entre outras coisas, o nome exato do instalador:

version: 0.4.0
files:
  - url: Caderno-Magico-Setup-0.4.0.exe
    sha512: FCFbi7bU3q...
    size: 98234112
path: Caderno-Magico-Setup-0.4.0.exe

O updater compara a version com a do app instalado e, se for maior, baixa o arquivo daquela urlpelo nome literal que está ali. Renomeie o .exe no upload e o updater vai pedir ao GitHub um arquivo que não existe mais. O app não quebra: ele simplesmente nunca mais atualiza, calado. 😬

Por isso os comentários no YAML são tão enfáticos:

- name: Build Electron — Windows (NSIS)
  # Mantém os nomes originais gerados pelo electron-builder: o latest.yml
  # referencia o instalador pelo nome exato, e o electron-updater precisa
  # baixar exatamente esse arquivo. Renomear quebraria o auto-update.
  run: npm run dist:win --workspace @caderno/electron

E é por isso que o DMG pode ser renomeado: no macOS o auto-update está desligado. Não há latest-mac.yml referenciando aquele nome, então o arquivo é só um download manual e o nome é livre. A regra, no fim, é fácil de lembrar: o que o updater lê, você não renomeia.

O que subir no Release, e por quê

- name: Upload assets para o Release (instalador + metadados de update)
  uses: softprops/action-gh-release@v2
  with:
    tag_name: ${{ github.ref_name }}
    name: ${{ github.ref_name }}
    generate_release_notes: true
    # latest.yml + .blockmap são lidos pelo electron-updater para detectar
    # e baixar (delta) a nova versão. Sem eles o auto-update não funciona.
    files: |
      electron/out/*.exe
      electron/out/*.exe.blockmap
      electron/out/latest.yml
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

São três arquivos, não um. O .exe é óbvio. O latest.yml é o que o updater consulta. E o .blockmap é o mapa que permite o download diferencial: em vez de baixar 100 MB de instalador, o updater compara os blocos e baixa só o que mudou.

Como os três jobs chamam a mesma action com a mesma tag_name, o primeiro cria o Release e os outros dois anexam os assets deles ao Release já existente. É por isso que não preciso de um quarto job só para juntar tudo.

Detalhe do generate_release_notes: true: ele monta o corpo do Release a partir dos PRs e commits desde a tag anterior. No meu caso, como commito direto na master sem PR, o GitHub gera só o link "Full Changelog" — pouco útil. Deixei ligado mesmo assim porque o link ajuda no histórico da web; só parei de exibir essas notas na modal de update do app, que ficava mostrando uma linha solitária.

🍏 Armadilha nº 5: o macOS compila, mas não atualiza sozinho

Este é o ponto em que preciso ser honesta com você, porque é fácil sair deste artigo achando que resolveu o Mac por completo. Resolveu metade.

O job do macOS gera um DMG que instala e roda. Mas ele não é assinado nem notarizado pela Apple — isso exige uma conta do Apple Developer Program, que é paga. Duas consequências, bem diferentes entre si:

Na primeira abertura, o Gatekeeper barra o app. O usuário precisa ir em Ajustes → Privacidade e Segurança e clicar em "Abrir Assim Mesmo". Chato, mas contornável — e é o que muito app pequeno faz.

No auto-update, a história é pior: no macOS a instalação de uma atualização falha silenciosamente se o app não for assinado. Não aparece erro, não aparece aviso; o download termina e nada acontece.

Por isso o código simplesmente desliga o updater lá:

function setupAutoUpdates() {
    // macOS fica de fora: o auto-update no Mac exige assinatura + notarização
    // Apple (conta paga); sem isso a instalação falha silenciosamente. No Mac
    // o usuário atualiza pelo download manual.
    if (process.platform === 'darwin') return;
    // Em dev (sem app empacotado) não há app-update.yml — checar quebraria.
    if (!app.isPackaged) return;

    // ...
}

Um recurso que falha em silêncio é pior que um recurso ausente: no segundo caso o usuário procura o download; no primeiro ele acha que já atualizou. Desligar explicitamente é a correção enquanto não há assinatura. 🙂

O if (!app.isPackaged) logo abaixo é primo desse cuidado: em desenvolvimento não existe o app-update.yml que o updater procura, e sem essa linha o app cospe um erro toda vez que você roda npm start.

⏱️ Armadilha nº 6: o shell: bash que mascarava a falha

Termino com a mais sutil de todas — e ela é específica do runner do Windows. 🪟

No windows-latest, o shell padrão do run: é o PowerShell. E o PowerShell tem um comportamento que morde: num bloco de várias linhas, ele avalia o sucesso pelo último comando. Este passo aqui é uma bomba-relógio:

- name: Build
  run: |
    npm run build:shared
    npm run build:frontend

Se o build:shared falhar e o build:frontend passar, o passo fica verde. O build segue em frente com o pacote shared desatualizado, e o erro só aparece muito depois — na melhor das hipóteses no empacotamento, na pior no app do usuário.

A correção é uma linha:

- name: Build shared + frontend + electron
  shell: bash
  run: |
    npm run build:shared
    npm run build:frontend
    npm run build --workspace @caderno/electron

O shell: bash no runner do Windows usa o Git Bash, e o Actions o invoca com set -eo pipefail — qualquer comando que falhe derruba o passo na hora. É o comportamento que a gente assume que existe. 😅

Vale a regra geral: em passo multi-linha no Windows, declare shell: bash. E lembre que a diferença não é só o encadeamento — caminhos, rm -f e as expansões tipo ${VAR#v} também dependem disso. Foi por isso que os passos de Instalar dependências e Derivar versão lá em cima carregam o shell: bash no job do Windows e dispensam no do Mac e do Linux.

🚀 Lançando uma versão

Com tudo no lugar, publicar virou três comandos:

git tag v0.4.0
git push origin v0.4.0
# e ir tomar um café ☕

Os três runners sobem juntos, cada um compila o seu, e o Release aparece com o .exe, o .dmg, o .AppImage e os metadados de update anexados. Da tag ao Release publicado, sem tocar em nenhuma máquina.

Errou a tag? Dá para desfazer, mas com cuidado — e só antes de alguém baixar:

git tag -d v0.4.0
git push origin :refs/tags/v0.4.0

Depois apague o Release pela interface do GitHub, senão a próxima tag com o mesmo nome vai anexar assets ao release velho. E se a versão já foi distribuída, não reaproveite o número: publique v0.4.1. Quem já instalou a 0.4.0 nunca mais vai receber uma "nova" 0.4.0 — para o updater, igual não é maior. 🙃

📦 O exemplo completo, para clonar

Montei um repositório com tudo o que este artigo explica, funcionando: um app de notas em Express + SQLite empacotado com Electron, e o workflow que compila os três instaladores. Cada uma das seis armadilhas está resolvida e comentada no ponto do código onde ela mora — não num bloco solto no topo.

cmacetko/Exemplos_ElectronActionsClone, rode npm install e npm start — o workflow está em .github/workflows/github.com

Duas coisas dele que valem o clique. A primeira é o endpoint /api/saude, que devolve um campo origemConfig: abra ele no app instalado e você vê na hora se o "bake" das URLs funcionou. Se disser localhost, a armadilha nº 2 pegou você. É o tipo de coisa que só um erro silencioso ensina a querer. 😅

A segunda é que dá para testar o workflow sem criar tag nenhuma: ele aceita workflow_dispatch, então você aperta "Run workflow" na aba Actions, ele compila os três sistemas e guarda os instaladores como artifacts, sem publicar Release. Bom para errar à vontade antes da primeira versão de verdade.

Se você quiser continuar daqui, o próximo passo natural é ligar o auto-update no app que agora sai compilado — já escrevi sobre isso:

ElectronJs — Configurando auto-updateFazendo o app se atualizar sozinho a cada novo releaseblog.palomamacetko.com.br

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também