0002. Preview e produção via GitHub Actions e branch gh-pages
Substitui o ADR 0001 e funde o escopo das issues #29 e #32.
Contexto
O ADR 0001 optou por um provedor
externo de staging (Netlify) para os previews por PR, evitando mexer na
configuração de Pages de produção — especificamente, pela integração
nativa via GitHub App desse provedor (a que oferece Deploy Previews e
comentário automático no PR prontos, sem código nosso). Na tentativa de
instalar essa integração, esbarramos em um bloqueio de governança: a
organização poabitdevs não lista membros públicos, e a página de
instalações da organização
(github.com/organizations/poabitdevs/settings/installations) retorna 404
para quem tentou o setup — apesar de ser admin do repositório, não há
confirmação de quem é Owner da organização, e instalar um GitHub App num
repositório de organização exige aprovação de um Owner. Esse bloqueio não é
específico do Netlify: Vercel e Cloudflare Pages usam o mesmo modelo de
integração nativa via GitHub App, então trocar de provedor não resolveria.
Isso não bloqueia necessariamente um deploy via CLI/API desses mesmos
provedores (por exemplo, netlify deploy autenticado por um token salvo
como secret do repositório), chamado de dentro de uma GitHub Action — essa
via não depende de instalar nenhum App na organização. Não avaliamos essa
alternativa a fundo porque a solução que adotamos abaixo (Actions + branch
gh-pages) já resolve o problema sem depender de conta, token ou secret
externo nenhum — mas registrando aqui para não parecer que descartamos um
caminho viável por omissão: o custo de mantê-lo seria gerenciar uma conta e
secrets de um provedor externo só para ganhar a conveniência do comentário
automático no PR, que de qualquer forma teríamos que implementar nós
mesmos na nossa própria Action.
Duas outras alternativas foram cogitadas: usar um repositório privado
pessoal só para homologação (rejeitada — fragmenta a governança de um
projeto comunitário para uma conta pessoal, e exigiria automação extra
para espelhar PRs do repositório oficial) e reavaliar o desenho original
considerado no ADR 0001 antes da escolha do provedor externo: publicar
previews em subcaminhos de uma branch gh-pages do próprio GitHub Pages.
Esse desenho tinha sido descartado por um motivo diferente — o GitHub Pages
serve apenas uma fonte de deploy por repositório, e a fonte configurada era
master (build legacy) — não por um problema de permissão. Ao revisitá-lo,
percebemos que ele resolve exatamente o bloqueio atual: uma GitHub Action
roda com o GITHUB_TOKEN do próprio repositório, sem exigir instalação de
App nenhum — só permissão de admin do repositório, que já temos.
Decisão
Buildar e publicar tanto os previews quanto a produção através de GitHub
Actions, usando a branch gh-pages como única fonte do GitHub Pages:
- Fonte do Pages: trocar de “legacy build a partir de
master” para a branchgh-pages, path/, com um arquivo.nojekyllna raiz — o GitHub passa a servir os arquivos estáticos como estão, sem reconstruir nada do lado dele. - Pré-requisito nos templates: os templates do site hoje usam
site.github.url(_includes/header.html,_includes/head.html) e caminhos absolutos de raiz —/events.htmlemindex.html,/feed.xmlem_includes/footer.html,/favicon.icoem_includes/head.html, `` emindex.htmleevents.html(gerado pelo Jekyll já relativo à raiz, sem levarbaseurlem conta) —, nenhum usasite.baseurl. Um preview publicado num subcaminho com esses templates navegaria de volta para produção. Antes do workflow de preview funcionar, os cinco arquivos (_includes/header.html,_includes/head.html,_includes/footer.html,index.html,events.html) precisam trocar essas referências pelo filtrorelative_urldo Jekyll. - Pré-requisito no
_config.yml: a chavebaseurljá está definida lá comohttps://poabitdevs.com— um domínio completo (e errado: o CNAME real do Pages époabitdevs.org) onde deveria haver só um path relativo vazio. Isso está adormecido hoje porque nenhum template usasite.baseurl/relative_url; assim que os templates forem corrigidos, esse valor passaria a vazar para qualquer build que não sobrescreva--baseurlexplicitamente — inclusive o de produção._config.ymlprecisa zerar essa chave (baseurl: "") antes da migração dos templates. - No PR (aberto, sincronizado ou reaberto, só de branches do próprio
repositório — ver “Fora de escopo” abaixo): uma Action builda o Jekyll a
partir de
refs/pull/<número>/merge(o merge sintético do PR com o estado atual demaster, mantido pelo próprio GitHub) combundle exec jekyll build --baseurl /pr-preview/pr-<número>, publica o_siteresultante emgh-pages:pr-preview/pr-<número>/e comenta o link automaticamente no PR. Buildar a partir desse ref, em vez do HEAD isolado da branch do PR, já deixa a árvore de fontes do preview equivalente à que a produção vai usar — semasteravançou entre um build e outro, o próximo push ao PR (ou o requisito de “up to date” no merge, abaixo) atualiza esse ref antes do merge acontecer de fato. - No fechamento do PR:
- Se mergeado: em vez de mover o diretório do preview como se fosse o
artefato final, a Action builda a produção de novo, com
bundle exec jekyll build(sem--baseurl, contando com obaseurl: ""corrigido no_config.yml— ver pré-requisito acima), a partir do HEAD atual demasterno momento em que a Action roda — não do commit de merge que disparou o evento, pela mesma lógica de coalescência descrita abaixo —, e publica esse resultado na raiz degh-pages, preservando opr-preview/já publicado (a raiz não é um subcaminho isolado: convive compr-preview/na mesma árvore — ver escrita conflict-safe abaixo). Isso abandona a garantia literal de “zero rebuild” — necessária porque o Jekyll grava obaseurlno HTML no momento do build, então o mesmo artefato do preview não pode ser servido correto em dois caminhos diferentes (subcaminho e raiz). A garantia que sobra é mais modesta, mas ainda real: árvore de fontes equivalente (o commit de merge real, criado no momento do merge, tem SHA distinto do que foi homologado no preview por definição — não é “o mesmo commit”) e mesmo toolchain/Gemfile.lock, dois builds determinísticos diferindo só nos caminhos dependentes debaseurl— não dois pipelines de build diferentes como no ADR 0001. - A branch
masterpassa a exigir “Require branches to be up to date before merging” e o status check do workflow de preview como obrigatório para o merge. A primeira regra sozinha não bastaria: ela garante que a árvore está fresca, mas não impede o merge enquanto o preview daquele estado específico ainda está rodando ou falhou — furando a premissa de que nada vai para produção sem ter sido homologado antes. As duas regras juntas fecham essa lacuna. - A publicação de produção usa seu próprio grupo de
concurrency(gh-pages-production), separado do grupo usado por preview/limpeza, que por sua vez é particionado por número do PR (gh-pages-preview-<número>) — não um grupo único compartilhado entre todos os PRs.concurrencynão é uma fila: por grupo, o GitHub Actions mantém no máximo uma execução em andamento e uma pendente — uma nova execução substitui a pendente, não entra atrás dela. Isso motiva as duas divisões: um grupo único entre produção e preview deixaria um preview de PR não relacionado cancelar um deploy de produção pendente; um grupo único de preview compartilhado entre PRs deixaria o evento de um PR B cancelar o preview pendente de um PR A — que não representa o estado de A — e, pior, deixaria a limpeza de A ao fechar ser cancelada por atividade de B, publicando o preview de A indefinidamente mesmo depois de fechado. - Mesmo isolado, o grupo de produção ainda pode coalescer dois merges
muito próximos (o mais antigo, pendente, é substituído pelo mais
novo). Por isso o job de publicação de produção não confia no SHA que
o disparou: ele sempre builda e publica o HEAD atual de
masterno momento em que roda, não o commit do evento que o originou. Assim, mesmo que uma execução intermediária seja descartada pela coalescência, a que efetivamente rodar por último publica o estado mais recente — nenhum merge fica de fora permanentemente. - Separar os grupos evita que um cancele o outro, mas não impede que
produção e preview tentem escrever em
gh-pagesao mesmo tempo — os grupos só serializam disparos dentro de si mesmos, não entre si. Como ambos partem do mesmo HEAD da branch, um push normal simultâneo falha por non-fast-forward (perdendo o deploy sem um retry) e um force-push apagaria a atualização de um pelo outro. Por isso, todo escritor degh-pages(preview, limpeza e produção) faz a escrita de forma conflict-safe: busca o HEAD atual, aplica sua mudança só no próprio subcaminho — o de cada PR, empr-preview/pr-<número>/; o de produção, em todo o restante da árvore da raiz, preservando intacto opr-preview/tal como acabou de ser buscado (sobrescrever a raiz por inteiro apagaria previews abertos ou, num retry, poderia ressuscitar um preview já limpo por outro escritor) —, tenta o push e, se falhar por non-fast-forward, repete o ciclo (fetch, reaplica preservando opr-preview/mais recente, push) até conseguir — nunca força a escrita. - Em qualquer fechamento — com ou sem merge —, o escritor de limpeza
(grupo
gh-pages-preview-<número>) remove o subcaminhopr-preview/pr-<número>/. Sem merge, essa é a única ação; com merge, ela roda em paralelo à publicação de produção (grupogh-pages-production), coordenada pela mesma escrita conflict-safe — sem essa limpeza, o preview de todo PR mergeado ficaria publicado indefinidamente.
- Se mergeado: em vez de mover o diretório do preview como se fosse o
artefato final, a Action builda a produção de novo, com
Fora de escopo nesta decisão: preview para PRs vindos de forks. No
evento pull_request, o GITHUB_TOKEN de um PR de fork é somente leitura
por restrição da própria plataforma GitHub — não escreveria em gh-pages
nem comentaria no PR. Resolver isso exigiria separar build (sem
privilégios, disparado por pull_request) de publicação (privilegiada,
disparada por workflow_run depois do build, sem nunca rodar código do
fork com token de escrita — o padrão seguro documentado pelo GitHub para
esse cenário). Como hoje o projeto não recebe contribuições via fork, essa
separação fica de fora por ora; revisitar se isso mudar.
Alternativas descartadas: provedor externo de staging (bloqueado por
governança da organização, ver Contexto — decisão original registrada no
ADR 0001); repositório privado
pessoal para homologação (fragmenta a governança do projeto); mover o
diretório do preview para a raiz sem rebuild (inviável — o baseurl
gravado no HTML no momento do build torna o mesmo artefato incorreto em
qualquer um dos dois caminhos).
Consequências
Positivas:
- Não depende de nenhum GitHub App externo nem de aprovação de Owner da organização — só de permissões de admin do repositório, já disponíveis.
- O artefato publicado em produção sai de uma árvore de fontes equivalente,
mesmo toolchain e mesmo
Gemfile.lockque o artefato homologado no PR — reduz bem a divergência de ambiente que motivou a issue #32, mesmo sem ser um artefato literalmente idêntico byte a byte nem vir do mesmo commit (ver “Decisão”). - Continua usando só a infraestrutura já em uso (GitHub Actions/Pages), sem provedor externo novo.
Negativas / trade-offs:
- Mexe no pipeline de produção (a fonte do GitHub Pages deixa de ser o
build legacy a partir de
master) — o risco que o ADR 0001 tinha deliberadamente adiado para depois é assumido agora. - Não elimina totalmente a divergência hml/prd: produção é buildada de novo
no merge (com
baseurldiferente do preview), não é uma promoção sem rebuild — objetivo original da issue #32 só parcialmente alcançado. - Exige corrigir os templates do site (
site.github.url, caminhos absolutos de raiz epost.url) para usarrelative_url, e zerar obaseurlincorreto do_config.yml, antes de qualquer preview funcionar corretamente. - O workflow de Actions fica mais complexo do que uma integração nativa de
provedor externo: precisa tratar PR aberto/sincronizado, fechado com
merge e fechado sem merge, usar um grupo de
concurrencypróprio para produção e um por PR para preview/limpeza, fazer a publicação de produção sempre reconciliar com o HEAD atual demaster(não com o SHA que disparou o job), e escrever emgh-pagesde forma conflict-safe (fetch + retry no push) em todo escritor, já que grupos separados não serializam a escrita entre si na mesma branch. - Previews não são suportados para PRs de forks nesta etapa (ver “Fora de escopo” acima) — só contribuições via branch do próprio repositório.
- Os previews continuam publicamente acessíveis (GitHub Pages não oferece PRs privados/protegidos por padrão), então nenhum conteúdo sensível deve passar por esse fluxo antes de estar pronto para publicação.
Próximos passos
- Zerar o
baseurldo_config.yml(hojehttps://poabitdevs.com, incorreto e adormecido). - Corrigir os templates (
_includes/header.html,_includes/head.html,_includes/footer.html,index.html,events.html) para usarrelative_urlem vez desite.github.url/caminhos absolutos/post.urlcru. - Implementar o workflow de Actions: build + publicação do preview com
baseurla partir derefs/pull/<N>/merge, comentário automático no PR, rebuild de produção no merge (sempre a partir do HEAD atual demasterno momento em que roda, não do SHA que disparou o job), limpeza do subcaminho de preview em todo fechamento (com ou sem merge) — produção com grupo deconcurrencypróprio (gh-pages-production) e preview/limpeza particionados por PR (gh-pages-preview-<N>), com escrita conflict-safe (fetch + retry no push) em todos os casos. - Habilitar “Require branches to be up to date before merging” e
marcar o status check do workflow de preview como obrigatório para o
merge, na
master. - Migrar a fonte do GitHub Pages para a branch
gh-pages(com.nojekyll). - Remover
netlify.toml(já feito) e reaproveitar.ruby-versionpara pinar a versão do Ruby no workflow de Actions.
Essas etapas ficam para os próximos PRs da issue #29, que passa a cobrir também o que estava reservado para a issue #32.
Emenda (2026-08-22): build e publicação separados também para PRs do próprio repositório
Achado de uma revisão do Copilot no PR
#36, durante a
implementação dos “Próximos passos” acima: a decisão original tratou o
GITHUB_TOKEN somente-leitura de PRs de fork como a razão suficiente para
adiar a separação build/publicação — mas essa é uma restrição que a própria
plataforma impõe só a forks. Para um PR de branch do próprio repositório,
não existe essa restrição: o token recebe o escopo declarado no workflow
(contents: write), e o evento pull_request executa a versão do
workflow do próprio PR, incluindo qualquer alteração que o PR faça no
workflow ou nos scripts que ele chama. Um PR desse tipo poderia, portanto,
editar o passo de publicação do preview (ou o de limpeza) para escrever
fora do seu subcaminho — inclusive na raiz de produção — só de ser aberto
ou atualizado, sem passar por review nem merge. Isso não era um risco já
aceito pela decisão original; é uma lacuna que ela não considerou, porque
avaliou só a mitigação automática de forks.
Correção: separar build de publicação também para PRs do próprio repositório, adiantando para agora o desenho que já estava previsto (na seção “Fora de escopo”) para quando o projeto passasse a aceitar forks:
- Build (
preview-build.yml, eventopull_request): builda o Jekyll a partir do código do PR, mas semcontents: writenem segredos — publica o_sitesó como artifact. Roda com código potencialmente não confiável, mas sem nada de valor para abusar. - Publicação (
preview-publish.yml, eventoworkflow_rundisparado pelo build): baixa o artifact e escreve emgh-pagescom o token de escrita — mas roda sempre com a versão do workflow que está emmaster, nunca a do PR (garantia do próprio eventoworkflow_run), então nunca executa código do PR no contexto privilegiado. - Limpeza (
preview-cleanup.yml, eventopull_request_targetem vez depull_request): mesma garantia de rodar sempre com o conteúdo demaster— e como a limpeza não precisa executar nada do PR (só apaga um subcaminho fixo pelo número do PR), não precisa do desenho build/publish em duas etapas, só da troca de evento.
production.yml não foi afetado: já rodava só em push para master,
nunca com conteúdo de PR.
Três achados adicionais de revisão, na mesma linha de “PR não pode se autoisentar da homologação”, refinaram esse desenho depois de implementado:
- O check que aparece no PR (
build, depreview-build.yml) só confirma que o Jekyll buildou, não que a publicação emgh-pagesou o comentário deram certo —workflow_runnão reporta status no commit do PR por conta própria.preview-publish.ymlpassou a criar/atualizar um commit statuspreview/publishdiretamente emworkflow_run.head_sha(pendente → sucesso/erro/falha); é o check exigido pela proteção de branch (ver “Próximos passos”). Só pode ser criado ali, nunca empreview-build.yml, que roda código do PR com permissões mínimas de propósito. workflow_runde execuções concorrentes não garante ordem: uma publicação atrasada de um commit antigo podia rodar depois da limpeza de um PR fechado, ressuscitando um preview já apagado, ou sobrescrever um preview mais novo com conteúdo velho.preview-publish.ymlagora consulta a API do PR antes de publicar e só segue se ele continuar aberto e no mesmohead.shaque disparou a execução; caso contrário, marca o status como erro (nunca sucesso) e não escreve nada — e, se o PR estiver especificamente fechado (não só superado), remove o preview como reforço (ver próximo ponto).- Limpar o preview não bastava para invalidar o status
preview/publishjá publicado nessehead.sha; um PR reaberto sem novo commit herdaria um check ainda “sucesso” com o preview já removido.preview-cleanup.ymlpassou a marcar esse status como erro depois de remover o preview. E, como esse job epreview-publish.ymlcompartilhavam o mesmo grupo deconcurrency(gh-pages-preview-<n>), uma sequência de publicações concluindo em cadeia podia deslocar/cancelar a limpeza enquanto ela ainda estava na fila — sem nada para rodá-la de novo, o preview de um PR fechado ficaria publicado indefinidamente.preview-cleanup.ymlpassou a ter grupo próprio (gh-pages-cleanup-<n>), nunca deslocável por tráfego de publish; o reforço do ponto anterior (publish também remove o preview ao detectar fechamento) cobre o caso de a limpeza dedicada ainda assim ser perdida por algum outro motivo.