← Papers
·

Infraestrutura · CI/CD · SEO Infrastructure · CI/CD · SEO

A Esteira de Publicação: do git push ao Google, sem Tocar em Nada The Publishing Pipeline: from git push to Google, Hands-Off

Como montei um pipeline em que escrevo só o HTML e o resto — deploy, domínio, privacidade, SEO e indexação — acontece sozinho. Com a arquitetura camada a camada e os erros reais que apareceram no caminho. How I built a pipeline where I only write the HTML and the rest — deploy, domain, privacy, SEO and indexing — happens on its own. With the architecture layer by layer and the real bugs that showed up along the way.

O Problema

The Problem

Eu queria publicar papers técnicos num domínio pessoal. A parte fácil é escrever. A parte chata é tudo em volta: subir o arquivo, configurar o domínio, garantir HTTPS, gerar sitemap.xml e robots.txt, avisar o Google que existe uma página nova — e, no meu caso, garantir que nada de privado vaze para a superfície pública, já que o repositório guarda contexto interno.

I wanted to publish technical papers on a personal domain. The easy part is writing. The annoying part is everything around it: uploading the file, configuring the domain, ensuring HTTPS, generating sitemap.xml and robots.txt, telling Google a new page exists — and, in my case, making sure nothing private leaks to the public surface, since the repo holds internal context.

Fazer isso à mão a cada paper é a receita perfeita pra desistir no terceiro. Então transformei o trabalho repetitivo numa esteira: um único gesto — git push — dispara todo o resto. Este paper documenta essa esteira, camada por camada, do jeito que eu gostaria de ter encontrado escrito quando comecei.

Doing this by hand on every paper is the perfect recipe for giving up on the third one. So I turned the repetitive work into a pipeline: a single gesture — git push — triggers everything else. This paper documents that pipeline, layer by layer, the way I wish I'd found it written when I started.

Princípio que guiou o desenho: eu só escrevo o conteúdo (um arquivo HTML). Deploy, domínio, privacidade, SEO e indexação são responsabilidade da máquina. Toda decisão abaixo serve a isso.

The principle that guided the design: I only write the content (one HTML file). Deploy, domain, privacy, SEO and indexing are the machine's job. Every decision below serves that.

Visão Geral da Esteira

Pipeline Overview

São cinco peças que se conectam. O git push aciona duas em paralelo (a Vercel e o GitHub Actions); as outras três foram configuradas uma vez e ficam no caminho da requisição ou do robô do Google:

Five pieces connect to each other. The git push triggers two in parallel (Vercel and GitHub Actions); the other three were configured once and sit in the path of the request or of Google's crawler:

O ponto não óbvio: deploy e SEO são trilhos separados. A Vercel cuida de servir o site; o GitHub Actions cuida de privacidade e SEO. Eles não dependem um do outro — se o sitemap falhar, o site ainda sobe; se a guarda de privacidade falhar, o merge é bloqueado antes de qualquer coisa ir ao ar.

The non-obvious point: deploy and SEO are separate rails. Vercel handles serving the site; GitHub Actions handles privacy and SEO. They don't depend on each other — if the sitemap fails, the site still ships; if the privacy guard fails, the merge is blocked before anything goes live.

Camada 1 — Deploy Automático com a Vercel

Layer 1 — Automatic Deploy with Vercel

A Vercel observa o repositório no GitHub. A cada push ela faz build e deploy. Como o site é HTML puro (sem framework, sem build step), a configuração é mínima — mas tem um detalhe que economiza muita dor: o Root Directory.

Vercel watches the GitHub repo. On every push it builds and deploys. Since the site is plain HTML (no framework, no build step), the config is minimal — but one detail saves a lot of pain: the Root Directory.

Root Directory = papers/

Root Directory = papers/

O repositório é um monorepo: tem skills, docs internos, scripts e a pasta papers/. Eu não quero que a Vercel sirva o repositório inteiro — só os papers. Apontando o Root Directory para papers/, a Vercel trata aquela pasta como a raiz do site. Tudo que está fora dela simplesmente não existe para o mundo. Essa é a primeira linha de defesa de privacidade, antes mesmo do CI.

The repo is a monorepo: it has skills, internal docs, scripts and the papers/ folder. I do not want Vercel serving the whole repo — only the papers. By pointing Root Directory at papers/, Vercel treats that folder as the site root. Everything outside it simply doesn't exist to the world. That's the first line of privacy defense, even before CI.

O bot da Vercel no Pull Request

The Vercel bot on the Pull Request

Esta foi a peça que mais me ajudou no dia a dia. Quando abro um PR, o bot da Vercel comenta automaticamente com uma preview URL: um deploy isolado daquela branch, com a aparência exata de como o paper ficará em produção. Eu reviso o paper renderizado antes de mergear — sem subir nada pro domínio real. Quando o PR é mergeado na main, a Vercel promove aquele mesmo build para produção.

This was the piece that helped me most day to day. When I open a PR, the Vercel bot automatically comments with a preview URL: an isolated deploy of that branch, looking exactly like the paper will in production. I review the rendered paper before merging — without pushing anything to the real domain. When the PR is merged into main, Vercel promotes that same build to production.

Fluxo mental: branch → PR → preview do bot → revisar → merge → produção. Eu nunca edito direto na main. Isso não é só boa prática — a política de segurança do repositório bloqueia push direto na main, então o caminho de PR é obrigatório de qualquer forma.

Mental flow: branch → PR → bot preview → review → merge → production. I never edit directly on main. This isn't just good practice — the repo's security policy blocks direct pushes to main, so the PR path is mandatory anyway.

URLs limpas: vercel.json

Clean URLs: vercel.json

Um pequeno arquivo de config dentro de papers/ controla dois comportamentos de URL e um redirect:

A small config file inside papers/ controls two URL behaviors and one redirect:

{
  "cleanUrls": true,
  "trailingSlash": false,
  "redirects": [
    {
      "source": "/2026-06-05-harness-study",
      "destination": "/harness-study",
      "permanent": true
    }
  ]
}

Camada 2 — Domínio e DNS na Cloudflare

Layer 2 — Domain and DNS on Cloudflare

Os papers ficam num subdomínio (papers.SEU-DOMINIO.dev), separado do site pessoal principal. Isso mantém os dois projetos independentes na Vercel e dá uma URL curta e temática.

The papers live on a subdomain (papers.YOUR-DOMAIN.dev), separate from the main personal site. This keeps the two projects independent on Vercel and gives a short, thematic URL.

O DNS está na Cloudflare. A configuração é um único registro:

DNS is on Cloudflare. The configuration is a single record:

Tipo:    CNAME
Nome:    papers
Destino: cname.vercel-dns.com
Proxy:   DNS only (nuvem cinza, não laranja)

A pegadinha que custa horas: deixar o proxy da Cloudflare ligado (nuvem laranja). Quando ligado, a Cloudflare intercepta o tráfego e tenta gerenciar o certificado SSL — o que conflita com a emissão de certificado da Vercel e quebra o HTTPS. A solução é DNS only (nuvem cinza): a Cloudflare só resolve o nome e deixa a Vercel cuidar do SSL.

The gotcha that costs hours: leaving Cloudflare's proxy on (orange cloud). When on, Cloudflare intercepts the traffic and tries to manage the SSL certificate — which conflicts with Vercel's certificate issuance and breaks HTTPS. The fix is DNS only (grey cloud): Cloudflare just resolves the name and lets Vercel handle SSL.

Do lado da Vercel, adiciona-se o domínio customizado no projeto (Settings → Domains). A Vercel detecta o CNAME, emite o certificado e o subdomínio passa a servir os papers com HTTPS automático e renovação automática.

On the Vercel side, you add the custom domain to the project (Settings → Domains). Vercel detects the CNAME, issues the certificate, and the subdomain starts serving the papers with automatic HTTPS and automatic renewal.

Camada 3 — Guarda de Privacidade no CI

Layer 3 — Privacy Guard in CI

O repositório é privado e guarda contexto interno — nomes, instituição, empresa. Os papers são públicos. O risco é óbvio: um dia eu colo um trecho num paper sem perceber que tem um nome próprio ali. A defesa é um script que falha o build se qualquer termo proibido aparecer na pasta papers/.

The repo is private and holds internal context — names, institution, company. The papers are public. The risk is obvious: one day I paste a snippet into a paper without noticing a proper name is in it. The defense is a script that fails the build if any forbidden term shows up in the papers/ folder.

Os termos ficam num arquivo de texto à parte (um por linha), separados da lógica do script:

The terms live in a separate text file (one per line), decoupled from the script logic:

# scripts/forbidden-terms.txt
# Um termo por linha. # = comentário. Case-insensitive.
NomeDaEmpresa
SiglaDaFaculdade
MinhaCidade

O coração do privacy-guard.sh é um grep recursivo, literal e case-insensitive (-rinIF) sobre papers/. Se acha qualquer termo, imprime exatamente onde vazou e sai com código de erro:

The heart of privacy-guard.sh is a recursive, literal, case-insensitive grep (-rinIF) over papers/. If it finds any term, it prints exactly where it leaked and exits with an error code:

while IFS= read -r term || [ -n "$term" ]; do
  [ -z "${term//[[:space:]]/}" ] && continue   # ignora linha vazia
  case "$term" in \#*) continue ;; esac        # ignora comentario

  if grep -rinIF -- "$term" "$TARGET" >/dev/null 2>&1; then
    echo "❌ LEAK: termo proibido '$term' encontrado em $TARGET/:"
    grep -rinIF -- "$term" "$TARGET" | sed 's/^/   /'
    found=1
  fi
done < "$TERMS_FILE"

[ "$found" -ne 0 ] && exit 1   # build falha → merge bloqueado

Detalhe deliberado: meu handle público do GitHub não está na lista de termos proibidos. Ele aparece no rodapé dos papers de propósito. A lista protege só o que deve ficar privado — não é um filtro cego, é uma fronteira escolhida. É o modelo "fonte privada, superfície pública": o repo sabe tudo, o site mostra só o permitido.

A deliberate detail: my public GitHub handle is not in the forbidden terms list. It shows up in the papers' footer on purpose. The list protects only what must stay private — it's not a blind filter, it's a chosen boundary. This is the "private source, public surface" model: the repo knows everything, the site shows only what's allowed.

Esse passo roda em todo evento do workflow — push e pull request. Como ele roda no PR, um vazamento é pego antes do merge, antes de qualquer deploy. A guarda é a primeira coisa que o CI executa.

This step runs on every event of the workflow — push and pull request. Because it runs on the PR, a leak is caught before merge, before any deploy. The guard is the first thing CI executes.

Camada 4 — SEO Automático: Sitemap Derivado do Git

Layer 4 — Automatic SEO: a Git-Derived Sitemap

Para o Google indexar bem, o site precisa de sitemap.xml, robots.txt e uma página índice listando os papers. Manter isso à mão a cada paper é exatamente o tipo de trabalho repetitivo que a esteira deve eliminar. O build-sitemap.sh gera os três a partir dos arquivos que existem em papers/.

For Google to index well, the site needs sitemap.xml, robots.txt and an index page listing the papers. Keeping that by hand on every paper is exactly the kind of repetitive work the pipeline should eliminate. build-sitemap.sh generates all three from whatever files exist in papers/.

As datas vêm do git, não do nome do arquivo

Dates come from git, not from the filename

Esta é a decisão de design da qual mais gosto. O slug do paper não tem data (/harness-study, não /2026-06-05-harness-study) — a URL é evergreen. Mas o sitemap precisa de uma data de modificação (lastmod). De onde ela vem? Do histórico do git:

This is the design decision I like most. The paper slug has no date (/harness-study, not /2026-06-05-harness-study) — the URL is evergreen. But the sitemap needs a modification date (lastmod). Where does it come from? From git history:

# publicado = primeiro commit do arquivo; atualizado = ultimo commit
# --follow sobrevive a renomeacoes do arquivo
git_first_date() { git log --follow --format=%cs -- "$1" | tail -1; }
git_last_date()  { git log --follow -1 --format=%cs -- "$1"; }

Resultado: a data nunca mente. Quando eu edito um paper, o lastmod sobe sozinho no próximo push, porque vem do commit real. A URL continua a mesma, o conteúdo evolui, e o Google sabe que a página foi atualizada. Nada disso exige que eu lembre de mexer numa data.

Result: the date never lies. When I edit a paper, lastmod bumps itself on the next push, because it comes from the real commit. The URL stays the same, the content evolves, and Google knows the page was updated. None of it requires me to remember to touch a date.

URL absoluta, ou o Google rejeita

Absolute URL, or Google rejects it

O domínio vem da variável SITE_URL (configurada no GitHub em Settings → Secrets and variables → Actions → Variables). O Google rejeita sitemaps com <loc> sem protocolo, então o script força https:// se faltar:

The domain comes from the SITE_URL variable (set on GitHub under Settings → Secrets and variables → Actions → Variables). Google rejects sitemaps with <loc> lacking a protocol, so the script forces https:// if it's missing:

SITE_URL="${SITE_URL%/}"            # tira barra final
case "$SITE_URL" in
  http://*|https://*) ;;            # ja tem protocolo
  *) SITE_URL="https://$SITE_URL" ;;  # forca https
esac

Esse case de três linhas existe porque eu fui mordido por isso em produção (mais sobre isso na seção de erros). O script gera o <loc> de cada paper, o robots.txt apontando para o sitemap, e um index.html listando os papers do mais novo pro mais antigo — tudo ordenado pela data de publicação derivada do git.

That three-line case exists because I got bitten by it in production (more on that in the bugs section). The script generates each paper's <loc>, the robots.txt pointing to the sitemap, and an index.html listing the papers newest-first — all ordered by the git-derived publication date.

Camada 5 — O Workflow que Cola Tudo

Layer 5 — The Workflow That Glues It Together

O GitHub Actions amarra a guarda de privacidade e a geração de SEO num só job. A lógica de quando cada passo roda é o que faz a esteira ser segura sem ser irritante:

GitHub Actions ties the privacy guard and the SEO generation into a single job. The logic of when each step runs is what makes the pipeline safe without being annoying:

- name: Privacy guard (falha se vazar)
  run: bash scripts/privacy-guard.sh papers

- name: Gerar sitemap + robots
  if: github.event_name == 'push'
  env:
    SITE_URL: ${{ vars.SITE_URL }}
  run: bash scripts/build-sitemap.sh

- name: Commitar sitemap se mudou
  if: github.event_name == 'push'
  run: |
    git add papers/sitemap.xml papers/robots.txt
    if git diff --cached --quiet; then
      echo "Sitemap sem mudancas — nada a commitar."
    else
      git commit -m "chore(papers): regenera sitemap e robots [skip ci]"
      git push
    fi

Dois detalhes finais: permissions: contents: write dá ao bot o direito de commitar de volta; e concurrency com cancel-in-progress garante que, se eu empurrar dois commits rápidos, só o último roda — sem corrida entre dois sitemaps.

Two final details: permissions: contents: write grants the bot the right to commit back; and concurrency with cancel-in-progress ensures that, if I push two quick commits, only the last one runs — no race between two sitemaps.

Camada 6 — Google Search Console

Layer 6 — Google Search Console

A última peça é manual, mas só uma vez. No Google Search Console, adiciona-se a propriedade (https://papers.SEU-DOMINIO.dev/) e verifica-se a posse — normalmente por um registro DNS na Cloudflare. Verificada a propriedade, envia-se o sitemap:

The last piece is manual, but only once. In Google Search Console, you add the property (https://papers.YOUR-DOMAIN.dev/) and verify ownership — usually via a DNS record on Cloudflare. Once verified, you submit the sitemap:

https://papers.SEU-DOMINIO.dev/sitemap.xml

Detalhe que me travou: o GSC quer a URL completa do sitemap, não só sitemap.xml. E a propriedade precisa estar verificada antes. Depois disso, a indexação é assíncrona — leva de horas a dias, e o painel mostra "Dados em processamento" no começo. Paciência faz parte.

A detail that tripped me up: GSC wants the full URL of the sitemap, not just sitemap.xml. And the property must be verified first. After that, indexing is asynchronous — it takes hours to days, and the dashboard shows "Data is being processed" at first. Patience is part of it.

A partir daí, o ciclo se fecha sozinho: cada paper novo entra no sitemap pelo CI, o Google relê o sitemap periodicamente e descobre as páginas novas sem que eu reenvie nada.

From then on, the loop closes itself: each new paper enters the sitemap via CI, Google re-reads the sitemap periodically and discovers new pages without me resubmitting anything.

Os Erros Reais (e como resolvi)

The Real Bugs (and how I fixed them)

A esteira não nasceu pronta. Documento os tropeços porque são exatamente o que eu reesqueceria — e reler isto é a melhor repetição espaçada que existe.

The pipeline wasn't born finished. I document the stumbles because they're exactly what I'd re-forget — and rereading this is the best spaced repetition there is.

  1. 404 na raiz do domínio 404 at the domain root A Vercel servia /harness-study, mas / dava 404 — não havia index.html em papers/. Fix: o build-sitemap.sh passou a gerar também um index.html listando os papers. A raiz virou a vitrine. Vercel served /harness-study, but / gave a 404 — there was no index.html in papers/. Fix: build-sitemap.sh now also generates an index.html listing the papers. The root became the showcase.
  2. "URL inválido" no sitemap (GSC) "Invalid URL" in the sitemap (GSC) A SITE_URL estava sem https://, então o <loc> saía sem protocolo e o Google recusava. Fix: o case que força https:// no script — agora é impossível gerar um sitemap inválido, mesmo se eu configurar a variável errado. The SITE_URL lacked https://, so <loc> came out protocol-less and Google refused it. Fix: the case that forces https:// in the script — now it's impossible to generate an invalid sitemap, even if I misconfigure the variable.
  3. Push direto na main bloqueado Direct push to main blocked A política de segurança do repo recusa push direto na main. Fix: abracei o fluxo de branch + PR — que, de quebra, ainda me dá a preview do bot da Vercel pra revisar antes do merge. A restrição virou um hábito melhor. The repo's security policy refuses direct pushes to main. Fix: I embraced the branch + PR flow — which, as a bonus, gives me the Vercel bot preview to review before merging. The constraint became a better habit.
  4. CI rodou com a variável errada na hora errada CI ran with the wrong variable at the wrong time Um merge disparou o CI antes de eu corrigir a SITE_URL, gerando um sitemap ruim. Fix: regenerei manualmente e commitei; e como o script agora corrige o protocolo sozinho, esse timing não morde mais. A merge triggered CI before I'd fixed SITE_URL, producing a bad sitemap. Fix: I regenerated manually and committed; and since the script now fixes the protocol on its own, that timing can't bite anymore.

Decisões de Design

Design Decisions

Decisão Decision Por quê Why
Root Directory = papers/ Root Directory = papers/ Só os papers vão ao ar; o resto do monorepo nunca é servido. Privacidade por construção. Only the papers ship; the rest of the monorepo is never served. Privacy by construction.
Deploy e SEO em trilhos separados Deploy and SEO on separate rails Vercel serve; Actions guarda e indexa. Um falhar não derruba o outro. Vercel serves; Actions guards and indexes. One failing doesn't take down the other.
Datas via git, não no slug Dates from git, not in the slug URL evergreen; lastmod sobe sozinho ao editar. A data nunca mente. Evergreen URL; lastmod bumps itself on edit. The date never lies.
Termos proibidos em arquivo separado Forbidden terms in a separate file Editar a fronteira de privacidade sem tocar na lógica do script. Edit the privacy boundary without touching the script logic.
[skip ci] no auto-commit [skip ci] on the auto-commit Evita o loop infinito de CI commitando e disparando a si mesmo. Avoids the infinite loop of CI committing and triggering itself.
DNS only na Cloudflare DNS only on Cloudflare Deixa o SSL com a Vercel; o proxy laranja quebraria o certificado. Leaves SSL to Vercel; the orange proxy would break the certificate.

O que Aprendi

What I Learned

O insight que levo: uma boa esteira não é a que automatiza mais coisas, é a que reduz o gesto humano a uma única decisão consciente. No fim, escrever um paper é só escrever um arquivo HTML e abrir um PR. Privacidade, deploy, domínio, SEO e indexação não são checklist meu — são propriedades garantidas pela máquina. Cada erro que documentei virou uma garantia no código, não uma nota mental que eu esqueceria.

The takeaway: a good pipeline isn't the one that automates the most things, it's the one that reduces the human gesture to a single conscious decision. In the end, writing a paper is just writing an HTML file and opening a PR. Privacy, deploy, domain, SEO and indexing aren't a checklist of mine — they're properties guaranteed by the machine. Each bug I documented became a guarantee in the code, not a mental note I'd forget.

Próximos Passos

Next Steps