GitHub Actions: compilando Electron para 3 sistemas
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.brTrê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:
🏗️ 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: env → buildConfig → localhost. 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:
| Job | runs-on | Comando | Sai |
|---|---|---|---|
| Windows | windows-latest | dist:win | .exe (NSIS) + .blockmap + latest.yml |
| macOS | macos-latest | dist:mac | .dmg |
| Linux | ubuntu-latest | dist: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 url — pelo 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.
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.brPor hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.