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
}
]
}
cleanUrls: serveharness-study.htmlem/harness-study— sem o.htmlna URL.cleanUrls: servesharness-study.htmlat/harness-study— no.htmlin the URL.trailingSlash: false:/harness-studye não/harness-study/, pra ter uma URL canônica só.trailingSlash: false:/harness-studynot/harness-study/, so there's a single canonical URL.- O
redirect301 (permanent) preserva um link antigo que tinha data no slug — quem tiver o link velho cai no novo, sem 404 e sem perder o histórico de SEO. - The 301
redirect(permanent) preserves an old link that had a date in the slug — anyone with the old link lands on the new one, no 404 and no lost SEO history.
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:
- Dispara só quando importa: o
paths:limita o workflow a mudanças empapers/**e nos scripts. Mexer numa skill não dispara o pipeline de papers. - Triggers only when it matters:
paths:limits the workflow to changes inpapers/**and the scripts. Touching a skill doesn't trigger the papers pipeline. - Guarda em todo evento; SEO só no push. O
privacy-guardroda em PR e em push. Obuild-sitemape o commit do SEO rodam só em push namain(if: github.event_name == 'push') — não faz sentido regenerar sitemap num PR ainda não mergeado. - Guard on every event; SEO only on push.
privacy-guardruns on PR and push.build-sitemapand the SEO commit run only on push tomain(if: github.event_name == 'push') — no point regenerating a sitemap on an unmerged PR. - Auto-commit sem loop infinito: depois de gerar o sitemap, o bot commita as mudanças. A mensagem leva
[skip ci]— senão esse commit dispararia o workflow de novo, que geraria o sitemap, que commitaria… para sempre. - Auto-commit without an infinite loop: after generating the sitemap, the bot commits the changes. The message carries
[skip ci]— otherwise that commit would trigger the workflow again, which would generate the sitemap, which would commit… forever.
- 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.
-
404 na raiz do domínio
404 at the domain root
A Vercel servia
/harness-study, mas/dava 404 — não haviaindex.htmlempapers/. Fix: obuild-sitemap.shpassou a gerar também umindex.htmllistando os papers. A raiz virou a vitrine. Vercel served/harness-study, but/gave a 404 — there was noindex.htmlinpapers/. Fix:build-sitemap.shnow also generates anindex.htmllisting the papers. The root became the showcase. -
"URL inválido" no sitemap (GSC)
"Invalid URL" in the sitemap (GSC)
A
SITE_URLestava semhttps://, então o<loc>saía sem protocolo e o Google recusava. Fix: ocaseque forçahttps://no script — agora é impossível gerar um sitemap inválido, mesmo se eu configurar a variável errado. TheSITE_URLlackedhttps://, so<loc>came out protocol-less and Google refused it. Fix: thecasethat forceshttps://in the script — now it's impossible to generate an invalid sitemap, even if I misconfigure the variable. -
Push direto na
mainbloqueado Direct push tomainblocked A política de segurança do repo recusa push direto namain. 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 tomain. 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. -
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 fixedSITE_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
- Adicionar verificação de links quebrados no CI, antes do deploy.
- Add broken-link checking to CI, before deploy.
- Gerar automaticamente tags Open Graph (preview ao compartilhar no LinkedIn) a partir do
<title>e da descrição de cada paper. - Auto-generate Open Graph tags (link preview when sharing on LinkedIn) from each paper's
<title>and description. - Estender a guarda de privacidade com padrões (regex) além de termos literais — e.g. formatos de e-mail internos.
- Extend the privacy guard with patterns (regex) beyond literal terms — e.g. internal email formats.