Pular para o conteúdo
Electron

Auto-update no Electron com GitHub Actions

Paloma Macetko
Ilustração: um unicórnio entrega uma caixa de software lacrada a um computador que mostra uma barra de progresso, ao lado de uma esteira mágica com pacotes para Windows, macOS e Linux

Olá meus Unicórnios! 🦄✨

Sabe quando você acha que já resolveu um assunto e três anos depois ele volta com o dobro do tamanho? 😅 Pois é.

Lá em 2023 eu escrevi dois artigos que fazem par: um ensinando a compilar uma aplicação Electron pelo GitHub Actions, e outro ensinando a atualizar essa aplicação sozinha usando o serviço gratuito update.electronjs.org.

Criando uma Action para compilar uma aplicação feita no ElectronJs utilizando GitHubCompilando para Windows e macOS direto na nuvem, mesmo sem ter um Macblog.palomamacetko.com.br ElectronJs — Configurando auto-updateO autoUpdater nativo do Electron apontando para o update.electronjs.orgblog.palomamacetko.com.br

Aqueles dois artigos continuam valendo — e se o seu repositório é público e o app é simples, eles resolvem sua vida em vinte minutos. Recomendo começar por ali.

Só que num app de notas em Electron que estou tocando — vou chamá-lo aqui de Caderno Estelar —, dois detalhes derrubaram aquela receita:

  • o repositório é privado — e o update.electronjs.org só funciona com repositório público;
  • o app precisa entregar Windows, macOS e Linux — cada um com um formato e regras diferentes de atualização.

Este artigo é a receita que sobrou depois de bater a cabeça: o workflow do GitHub Actions que compila e publica os três, o electron-updater lendo o release, e um proxy no backend para o repositório privado. Mais — e esta é a parte que ninguém escreve — as três armadilhas que me custaram horas, todas registradas em comentário no código-fonte porque eu não queria esquecer. 🙏

E se você prefere o código antes da explicação, está tudo num repositório de exemplo, pronto para clonar e rodar:

cmacetko/Exemplos_ElectronAutoUpdateO workflow, o backend Express e o app Electron completos — com as armadilhas já resolvidas e comentadasgithub.com

🧭 O mapa: quem faz o quê

Antes do código, o desenho. Auto-update parece uma coisa só, mas são quatro peças conversando — e quase todo bug mora na fronteira entre duas delas:

git push --tags v0.4.0
      │
      ▼
GitHub Actions  ── compila Win / macOS / Linux ──┐
                                                 ▼
                              GitHub Release (assets)
                              ├── Caderno-Estelar-Setup-0.4.0.exe
                              ├── Caderno-Estelar-Setup-0.4.0.exe.blockmap
                              ├── latest.yml          <── o catálogo
                              ├── Caderno-Estelar-0.4.0.AppImage
                              ├── latest-linux.yml
                              └── Caderno-Estelar-0.4.0.dmg
                                                 │
                     (repo privado: passa pelo backend)
                                                 ▼
                              electron-updater no app
                                                 │
                                    canal IPC   ▼
                                       modal no React

O arquivo mais importante dessa lista não é o instalador: é o latest.yml. Ele é um YAML minúsculo que o electron-builder gera junto com o binário, e é ele que diz ao app "a versão nova é a 0.4.0, o arquivo se chama assim, e o SHA512 dele é este". Sem o latest.yml no release, o app nunca descobre que existe atualização — e essa é a falha número um de quem monta isso pela primeira vez.

⚙️ O workflow que compila os três sistemas

A Action dispara quando você empurra uma tag que começa com v — igual à do artigo de 2023, essa parte não mudou:

name: Release Desktop (Windows · macOS · Linux)

on:
  push:
    tags:
      - "v*"

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

Repare no permissions: contents: write. Sem essa linha o job compila lindamente, chega no passo de publicar e morre com 403. O GITHUB_TOKEN automático vem com permissão de leitura por padrão nos repositórios mais novos, e criar um Release é escrita.

São três jobs irmãos — build-windows, build-macos e build-linux — rodando em paralelo, cada um em seu runner. Vou usar o de Windows como exemplo, porque é o mais completo:

jobs:
  build-windows:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v5
        with:
          node-version: "22"
          cache: "npm"

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

Este passo parece burocrático e é justamente o que evita o bug mais chato do auto-update. A versão que o app compara é a do package.json; a versão que o release anuncia é a da tag. Se as duas divergirem, o app baixa a atualização, instala, reinicia — e oferece a mesma atualização de novo, em loop eterno. 🔁

A solução é ter uma única fonte da verdade: a tag manda, o package.json obedece.

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

      - 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');"

O ${GITHUB_REF_NAME#v} é expansão do bash: tira o v da frente. A tag v0.4.0 vira a versão 0.4.0, que é o formato que o semver espera.

O npm ci que não funciona fora do Windows

Esta eu confesso que demorei a entender, porque o erro só aparecia nos runners de macOS e Linux — no meu Windows local rodava liso. 😳

      # 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

O package-lock.json foi gerado na minha máquina Windows, e ferramentas como Rollup e esbuild distribuem binários por plataforma em pacotes opcionais. O lock só registrou os do Windows. O npm ci, que é rígido por design e instala exatamente o que está no lock, chega no runner Linux e não acha o binário dele.

Apagar o lock e usar npm install é um trade-off consciente: você perde a reprodutibilidade exata em troca de o build funcionar nos três sistemas. Se isso te incomoda — e é legítimo que incomode — a alternativa é commitar um lock gerado com --force incluindo os opcionais das três plataformas.

Segredos entram no build, não no repositório

URLs de backend e DSN do Sentry não vão para o Git. Elas ficam nos Secrets do repositório (Settings → Secrets and variables → Actions, o mesmo caminho do artigo do FTP) e são injetadas no arquivo de configuração na hora do build:

      - name: Bake URLs no buildConfig (a partir dos secrets)
        shell: bash
        env:
          CE_BACKEND_URL: ${{ secrets.CE_BACKEND_URL }}
          CE_WEB_URL: ${{ secrets.CE_WEB_URL }}
        run: |
          node -e "const fs=require('fs');const f='electron/src/buildConfig.ts';const b=process.env.CE_BACKEND_URL||'';const w=process.env.CE_WEB_URL||'';if(!b||!w){console.error('CE_BACKEND_URL/CE_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);"

O process.exit(1) no meio é de propósito: se o secret estiver faltando, eu quero que o build falhe agora, alto e claro. Sem ele, o app seria gerado com a URL vazia, publicado, instalado — e só o usuário descobriria, na forma de um app que não conecta em lugar nenhum.

Um detalhe honesto: o segredo fica dentro do binário distribuído, então isso não protege chave sensível — quem abrir o pacote acha. Serve para não versionar configuração de ambiente, não para guardar credencial.

Compilar e publicar

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

      - 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 @estelar/electron

Leia o comentário desse passo — ele é a armadilha zero. Dá muita vontade de renomear o artefato para algo bonito antes de publicar (MeuApp-Windows.exe, por exemplo). Não faça no Windows nem no Linux: o latest.yml aponta para o arquivo pelo nome. Você renomeia, o catálogo continua pedindo o nome antigo, e o updater recebe 404 num download que já tinha anunciado ao usuário.

No macOS eu renomeio — e posso, porque lá não existe latest.yml no meu fluxo (já explico por quê).

Agora a publicação, que é onde mora a primeira armadilha de verdade:

      - 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 }}

Três padrões no files, não um. É tentador subir só o .exe — é ele o instalador, afinal. Mas sem o latest.yml o app nunca fica sabendo que há versão nova: ele pergunta pelo catálogo, recebe 404 e conclui, em silêncio, que está atualizado. Nenhum erro na tela, nenhum log gritando. Só um app que nunca atualiza. 😖

O job do Linux é o mesmo com latest-linux.yml e *.AppImage. O do macOS sobe só o .dmg.

🍎 Por que o macOS ficou de fora do auto-update

Esta é uma decisão que eu adiei o quanto pude e acabei tomando na marra.

Auto-update no macOS exige assinatura de código e notarização da Apple — o que significa uma conta de desenvolvedor paga. Sem isso, o Squirrel.Mac (o mecanismo por baixo do electron-updater no Mac) recusa o pacote. E o pior: recusa quietinho. O usuário vê o download completar, clica em reiniciar, o app volta… na mesma versão. Nenhuma mensagem de erro.

Um update que falha em silêncio é pior que update nenhum, porque destrói a confiança do usuário na atualização inteira. Então eu escolhi ser explícita:

function setupAutoUpdates(): void {
    if (process.platform === "darwin") return;
    // Em dev (sem app empacotado) não há app-update.yml — checar quebraria.
    if (!app.isPackaged) return;

No Mac o app compila, é publicado e é baixado normalmente — o usuário só atualiza manualmente, baixando o .dmg novo. E quando ele clica em "Buscar atualizações" no menu, recebe uma mensagem dizendo exatamente isso, em vez de um botão que não faz nada.

A segunda linha vale para todo mundo: em desenvolvimento não existe app-update.yml (esse arquivo só é criado quando o electron-builder empacota). Chamar o updater sem ele estoura uma exceção feia toda vez que você aperta F5 no seu ambiente local.

🔐 Repositório privado: o proxy que faltava

Chegamos ao motivo pelo qual o artigo de 2023 não me servia mais.

O update.electronjs.org é um serviço gratuito e ótimo — mas só lê repositórios públicos. O provider github do electron-builder até fala com repositório privado, só que exigindo um token do GitHub dentro do app instalado na máquina do usuário. Distribuir um token de acesso ao seu repositório privado para todo mundo que instalar o app é… não. 🙅‍♀️

A saída foi montar um feed público que fala em nome do app: uma rota no meu backend que, autenticada com um token que nunca sai do servidor, busca o release no GitHub e repassa. Para o electron-updater, aquilo é apenas um servidor comum de arquivos — o provider generic:

// Repo privado: o cliente não tem token do GitHub. O backend proxia o release
// (latest.yml + binários) em /api/updates; aqui usamos o provider generic apontando
// para esse feed. Sobrescreve o build.publish (github), que segue só para o publish do CI.
autoUpdater.setFeedURL({
    provider: "generic",
    url: `${backendUrl()}/api/updates`,
});

Repare no detalhe do comentário: o publish do package.json continua sendo github. Ele agora serve só para o lado do CI — é como o electron-builder sabe montar o latest.yml. Quem lê, no cliente, é o generic. Os dois convivem apontando para lugares diferentes, e isso é intencional.

Do lado do backend, a rota é um proxy deliberadamente burro:

/** Sufixos permitidos no feed de update (evita proxy arbitrário do GitHub). */
const ALLOWED_SUFFIXES = [".yml", ".exe", ".appimage", ".blockmap"];

function isAllowed(filename: string): boolean {
  const lower = filename.toLowerCase();
  return ALLOWED_SUFFIXES.some((s) => lower.endsWith(s));
}

A lista branca não é paranoia decorativa. Sem ela, /api/updates/<qualquer-coisa> viraria um proxy autenticado para qualquer asset do meu repositório privado — de graça, para a internet inteira. Cinco linhas que fecham a porta. 🔒

E um princípio que vale tatuar: o latest.yml é repassado byte a byte, sem reescrita.

/**
 * Proxy puro: o corpo (inclusive latest.yml) é repassado sem reescrita, preservando o SHA512
 * que o electron-builder gravou. Montado ANTES do requireCloudflare/auth (o updater roda no
 * processo main, sem cookies de sessão).
 */

O latest.yml carrega o SHA512 do instalador, e o electron-updater confere esse hash depois de baixar. Se você "melhorar" o YAML no caminho — trocar uma URL, normalizar uma quebra de linha — o hash deixa de bater e o update é rejeitado com uma mensagem que não ajuda em nada.

A segunda frase do comentário é igualmente importante: essa rota tem que ficar antes do middleware de autenticação. O updater roda no processo main do Electron, não no navegador — ele não tem cookie de sessão nenhum. Se a rota cair atrás do login, o app recebe 401 e conclui que está atualizado.

O cache de 5 minutos completa o desenho:

// Cache em memória do release bruto (~5 min). Evita estourar o rate-limit da API do
// GitHub: uma checagem do updater pede latest.yml + binário + blockmap.
const TTL_MS = 5 * 60 * 1000;

Uma única atualização de um único usuário faz três requisições. Multiplique por todo mundo abrindo o app na segunda de manhã e você estoura o limite da API do GitHub antes do café. ☕

🐌 Duas esperas que quase me convenceram de que estava tudo quebrado

Se você só for ler um pedaço deste artigo, leia este. 🙏 As duas coisas abaixo não estão em documentação nenhuma — eu descobri as duas cronometrando, e as duas se manifestavam do mesmo jeito: o app parecia travado.

1. O download delta que atrasa o download

O electron-updater tem um recurso lindo no papel: em vez de baixar o instalador inteiro, ele usa o .blockmap para descobrir quais pedaços mudaram desde a sua versão e baixa só a diferença. Menos banda, atualização mais rápida.

Só que atrás do meu proxy ele fazia isso antes de começar o download visível — e o resultado eram dois minutos de tela parada:

// Desativa o download DIFERENCIAL (delta via .blockmap). Atrás do nosso proxy
// (/api/updates → GitHub), o updater baixava e processava o blockmap ANTES de iniciar
// o .exe — o que dava ~2 min de espera até o download "começar". Sem o delta, ele baixa
// o instalador inteiro direto: o progresso aparece quase imediatamente.
autoUpdater.disableDifferentialDownload = true;

O usuário clicava em "Atualizar agora" e ficava olhando para 0%. Eu jurei que era bug no meu proxy — passei uma tarde inteira instrumentando a rota errada. 😳 Não era: era o updater trabalhando, só que numa etapa invisível.

Trocar delta por download completo é abrir mão de banda em troca de feedback imediato. Num app de poucas dezenas de megabytes, ver a barra andar vale muito mais que economizar uns megas.

2. O header Authorization que o storage do GitHub rejeita

Esta é a minha favorita, porque a causa é absurdamente específica.

A API do GitHub não serve o binário direto: ela responde 302 apontando para objects.githubusercontent.com, com a autorização embutida na própria URL. E o fetch, por padrão, segue redirect repassando os headers originais — inclusive o meu Authorization: Bearer <token>.

O storage recebe uma URL já assinada e um header de autorização que ele não espera. Rejeita. O cliente tenta de novo. Rejeita de novo. Resultado: minutos até o primeiro byte, e a janela do app congelada esperando.

A correção foi parar de seguir o redirect e apenas ler para onde ele aponta:

async resolveSignedUrl(assetUrl: string): Promise<string> {
    this.requireToken();
    const res = await this.fetchFn(assetUrl, {
      headers: this.headers("application/octet-stream"),
      redirect: "manual",
    });
    // 3xx esperado: a URL assinada vem no Location.
    const location = res.headers.get("location");
    if (res.status >= 300 && res.status < 400 && location) return location;
    // Sem redirect (ex.: mock/local que serve direto): cai para o proxy.
    throw new AppError(502, "github_no_redirect", "Falha ao resolver o download.");
}

O redirect: "manual" é o coração da coisa: o token vai para api.github.com, e o que volta é a URL assinada do storage, limpa.

Com ela em mãos, o backend faz algo que melhora tudo de uma vez — devolve um 302 para o próprio app, em vez de bombear os bytes por si mesmo:

// BINÁRIOS (.exe/.appimage/.blockmap): redireciona o cliente direto para a URL
// ASSINADA do storage do GitHub, em vez de proxiar os bytes pelo backend.
// O latest.yml continua proxiado (pequeno; o feed precisa servir o conteúdo exato).
if (!isYaml) {
  try {
    const signed = await service.resolveSignedUrl(asset.url);
    res.redirect(302, signed);
    return;
  } catch {
    // fallback: proxia abaixo.
  }
}

Duas vitórias numa tacada: o download começa na hora, e os megabytes do instalador não passam mais pela minha banda. O latest.yml continua proxiado de propósito — é minúsculo, e o feed precisa servir o conteúdo exato para o SHA512 bater.

E olha o catch vazio: se a resolução do redirect falhar, ele volta ao proxy tradicional. Lento, mas funciona. Um caminho degradado é melhor que uma atualização quebrada.

🎛️ Quem manda no update é o usuário

Com o feed no lugar, sobra decidir quando as coisas acontecem. Minha escolha:

// O renderer controla todo o fluxo via modal: decide quando baixar e quando instalar.
autoUpdater.autoDownload = false;          // só baixa após "Atualizar agora"
autoUpdater.autoInstallOnAppQuit = false;  // "mais tarde" não instala sozinho

O padrão do electron-updater é baixar sozinho e instalar quando o app fecha. Eu desliguei os dois, e a razão é a natureza do app: é um app de notas. Consumir a internet de alguém sem avisar é feio; reiniciar o app enquanto a pessoa escreve é pior. "Mais tarde" tem que significar mais tarde de verdade.

Os eventos viram mensagens para a interface por IPC:

autoUpdater.on("checking-for-update", () => emitUpdateStatus({ state: "checking" }));

autoUpdater.on("update-available", (info) => {
    userTriggeredCheck = false;
    emitUpdateStatus({ state: "available", version: info.version });
});

autoUpdater.on("download-progress", (p) =>
    emitUpdateStatus({ state: "downloading", percent: Math.round(p.percent) }),
);

autoUpdater.on("update-downloaded", (info) => {
    // Instalação é sempre disparada pelo renderer (botão "Reiniciar para instalar").
    emitUpdateStatus({ state: "downloaded", version: info.version });
});

E o caminho de volta, os dois botões da modal:

ipcMain.on("desktop-update-download", () => {
    void autoUpdater.downloadUpdate();
});

ipcMain.on("desktop-update-install", () => {
    autoUpdater.quitAndInstall();
});

Agora o detalhe mais sutil do artigo inteiro — e o que mais me custou a entender, porque o bug era intermitente:

// Só checa depois que o renderer carregou — assim o banner está montado para
// receber os eventos de status (update-available/downloaded podem chegar cedo).
const check = () => void autoUpdater.checkForUpdates();
if (mainWindow?.webContents.isLoading() === false) check();
else mainWindow?.webContents.once("did-finish-load", check);

Se você chama checkForUpdates() assim que a janela é criada, numa conexão rápida a resposta do feed chega antes de o React montar o componente que escuta o canal. O evento é disparado, ninguém está ouvindo, e ele some — não há replay. O app tem atualização e a modal nunca aparece.

Em máquina lenta funcionava; na minha, não. Clássico. 😅

Note também que o if/else não é preciosismo: se a janela já terminou de carregar, o evento did-finish-load já passou e nunca mais dispara. Registrar o listener sem checar antes significa não checar atualização nenhuma.

Do outro lado, no React, a modal é uma máquina de estados de quatro fases:

export function UpdateModal() {
  const [status, setStatus] = useState<DesktopUpdateStatus>({ state: "idle" });
  const [dismissed, setDismissed] = useState(false);

  useEffect(() => {
    const bridge = typeof window !== "undefined" ? window.estelarDesktop : undefined;
    if (!bridge?.onUpdateStatus) return;
    return bridge.onUpdateStatus((s) => {
      setStatus(s);
      // Uma nova fase relevante reabre a modal caso tenha sido adiada.
      if (s.state === "available" || s.state === "downloaded") setDismissed(false);
    });
  }, []);

  if (status.state === "idle" || status.state === "checking") return null;
  if (dismissed) return null;
  // … available / downloading / downloaded / error
}

O if (!bridge?.onUpdateStatus) return é o que permite o mesmo componente rodar no navegador: sem a ponte do Electron, ele simplesmente não faz nada. E o return do useEffect devolvendo a função de limpeza evita listener duplicado a cada re-render — no modo estrito do React isso apareceria na hora, como duas modais empilhadas.

📦 O código completo

Montei um repositório com tudo que este artigo mostra, funcionando junto: o workflow que compila os três sistemas, o backend em Express que serve o feed, e o app Electron com a modal de atualização. É só clonar, rodar npm install nas duas pastas e seguir o README.

cmacetko/Exemplos_ElectronAutoUpdateWorkflow, backend e app Electron — as nove armadilhas resolvidas e comentadas no ponto onde cada uma moragithub.com

Cada correção que contei aqui está marcada com um comentário no lugar exato onde ela importa — inclusive o redirect: "manual" e o disableDifferentialDownload. Se o seu repositório for público, apague a pasta backend/ e use direto o provider github: o resto continua igual. 🦄

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

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

Leia também