Publicando no PyPI e no npm sem token: Trusted Publishing (OIDC) com GitHub Actions

Published on: 2026-07-19
Post image
pt pypi npm trusted-publishing oidc github-actions python javascript ci-cd seguranca

Publicar um pacote quase nunca é um comando só na sua máquina. Normalmente a gente deixa isso a cargo do CI — um servidor que roda tarefas automáticas cada vez que você envia código (aqui vamos usar o GitHub Actions, o CI que vem embutido no GitHub). E, por anos, a receita foi: gerar um token de publicação (uma senha longa que autoriza subir o pacote) e guardá-lo como secret — um segredo salvo nas configurações do repositório.

Funciona, mas é uma bomba-relógio. Esse token é uma chave que fica guardada: se ela vaza num log, num fork do projeto ou numa dependência maliciosa, qualquer um passa a publicar no seu lugar. Tanto que o cerco está fechando — o npm já anunciou que vai restringir tokens que burlam o 2FA (para contas em agosto/2026 e para publicação direta em janeiro/2027).

A alternativa moderna é o Trusted Publishing (publicação confiável) usando OIDC (OpenID Connect), um padrão de identidade. A ideia, numa analogia: em vez de deixar uma chave-mestra guardada embaixo do tapete, o seu robô de publicação chega na portaria e mostra um crachá temporário que o próprio GitHub emite na hora. O porteiro (o PyPI ou o npm) confere se aquele crachá é mesmo daquele projeto e daquele processo — e só então deixa publicar. Nada de segredo salvo em lugar nenhum.

Neste guia você vai configurar do zero os dois lados — PyPI (o registro oficial de pacotes Python) e npm (o do JavaScript) — e, no fim, publicar vira só criar uma tag de versão. Vou incluir de propósito todos os perrengues que enfrentei na prática, pra você pular direto para a parte que funciona. 🚀

Fluxo do Trusted Publishing: o GitHub Actions prova a identidade via OIDC e publica no PyPI e no npm, sem token guardado
O robô prova quem é (via OIDC) e o registro publica — sem nenhum segredo armazenado.

Como o "crachá" (OIDC) substitui o token

Toda tarefa no GitHub Actions roda dentro de um job (um passo do processo). Quando você dá a esse job a permissão id-token: write, o GitHub emite um crachá de identidade — um token curto, assinado e que expira em minutos — descrevendo exatamente de onde aquele job saiu: a organização, o repositório e o arquivo de workflow (a receita de automação). O registro de pacotes foi configurado, do lado dele, para confiar só nessa combinação específica. A ferramenta de publicação troca esse crachá por uma permissão temporária e sobe o pacote. Um fork, outro workflow ou um token vazado simplesmente não conseguem forjar o crachá.

O que você vai precisar

  • Seu código num repositório do GitHub, com o Actions ligado.
  • Um workflow de release — o arquivo de receita que roda quando você cria uma tag (um marcador de versão no Git, ex.: v0.0.1).
  • A permissão id-token: write no job — é ela que libera a emissão do crachá.
  • Contas no PyPI e/ou no npm com 2FA (verificação em duas etapas) ativo.

Parte 1 — PyPI (Python)

O PyPI tem um detalhe que facilita muito a vida: ele aceita um pending publisher (algo como "publicador pendente"). Ou seja, você cadastra a confiança antes mesmo do pacote existir no site. A primeira tag que você enviar já cria o projeto e publica de uma vez — sem precisar de um primeiro envio manual, como veremos que o npm exige.

1. Registrar o pending publisher

Em pypi.org → Account settings → Publishing → Add a pending publisher, escolha GitHub e preencha:

  • PyPI Project Name — o nome do pacote no PyPI (ex.: mcp-gtw).
  • Owner — a organização ou usuário do GitHub (ex.: mcp-gtw).
  • Repository name — o nome do repositório.
  • Workflow name — só o nome do arquivo (ex.: release.yml).
  • Environment name — deixe em branco, a menos que use GitHub Environments.

💡 O nome de distribuição no PyPI pode ser diferente do nome de importação. Se o nome desejado já estiver ocupado, publique com outro (ex.: meu-pkg) e mantenha o import meu_pacote — só ajuste o importlib.metadata.version("nome-de-distribuicao") para bater com o nome publicado.

2. O workflow de release

Esse é o arquivo da receita, que fica em .github/workflows/release.yml no seu repositório. Repare no que não tem: nenhuma linha de env com segredo, nenhum PYPI_API_TOKEN. Só a permissão id-token: write (o crachá) e o parâmetro --trusted-publishing always. Aqui uso o uv para build e publish, mas o action oficial pypa/gh-action-pypi-publish funciona igual:

name: release

on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - name: Install uv
        run: pip install uv
      - name: Build
        run: uv build
      - name: Publish to PyPI
        run: uv publish --trusted-publishing always

3. Publicar

Suba a versão, crie a tag e empurre — o CI builda e publica sozinho:

git tag v0.0.1
git push origin v0.0.1

Em segundos o pacote aparece em https://pypi.org/project/seu-pacote/. No meu caso, o PyPI subiu de primeira. ✅

Parte 2 — npm (JavaScript)

No npm a ideia é a mesma, mas o caminho tem duas pegadinhas que me custaram várias tentativas frustradas. Vou te entregar já resolvidas, pra você não perder o tempo que eu perdi.

1. Versões de Node e npm

O suporte a Trusted Publishing é recente, então ele exige npm ≥ 11.5.1 e Node ≥ 22.14. O problema: nem o Node 22 ou 24 já vem com um npm tão novo por padrão. A saída é atualizar o npm dentro do próprio workflow, antes de publicar:

npm install -g npm@latest

Sem isso, o npm nem tenta o OIDC — cai direto para token e falha.

2. Pacote novo? O primeiro publish é manual

Diferente do PyPI, o npm não deixa pré-configurar um pacote que ainda não existe: a tela de Trusted Publisher só aparece depois que o pacote existe. Então, só na estreia, crie o pacote com um publish manual:

npm login
npm publish --access public

3. As DUAS partes da configuração (aqui mora o perrengue)

Na página do pacote em npmjs.com → Settings existem duas seções separadas, e você precisa das duas:

  1. Trusted Publisher → em Select your publisher, clique em GitHub Actions e preencha organização, repositório e workflow (release.yml), deixe o environment vazio, marque Allow npm publish e clique em Set up connection.
  2. Publishing access → marque Require two-factor authentication and disallow tokens (recommended) e salve.

⚠️ O erro que eu cometi: configurei só a segunda parte (proibir tokens) e achei que tinha acabado. Como os tokens estavam proibidos e não havia trusted publisher configurado, não existia forma de autenticar — e todo publish falhava com 404 Not Found ou ENEEDAUTH. A lição: configure a conexão de Trusted Publisher primeiro. A opção de "disallow tokens" sozinha só fecha a porta dos tokens.

4. O workflow de release

name: release

on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          registry-url: "https://registry.npmjs.org"
      - name: Upgrade npm for trusted publishing
        run: npm install -g npm@latest
      - name: Install
        run: npm ci
      - name: Publish
        run: npm publish --access public

5. Um mito desfeito: o registry-url não é o vilão

Durante a depuração, o registry-url do setup-node parecia culpado: ele escreve um .npmrc com um _authToken vazio, e cheguei a achar que isso bloqueava o OIDC (o erro virou ENEEDAUTH quando o removi). Era pista falsa. O exemplo oficial do npm usa o registry-url, e o npm ≥ 11.5.1 tenta o OIDC antes de cair para token. A causa real dos dois erros era sempre a mesma: o trusted publisher não estava configurado. Mantenha o registry-url e configure a conexão no site.

Conferindo o release

Com o gh CLI você acompanha o run e, se falhar, lê só o passo que quebrou:

gh run list --workflow=release.yml --limit 1
gh run view <run-id> --log-failed

E confirma direto nos registries:

npm view seu-pacote version
curl -s https://pypi.org/pypi/seu-pacote/json | jq .info.version

💡 O endpoint JSON do PyPI tem cache de alguns minutos: logo após publicar, ele pode ainda mostrar a versão anterior mesmo com o upload já concluído. O log do job (Uploading ...whl) é a fonte da verdade.

Os perrengues, em forma de checklist

Se algo der errado, é bem provável que seja um destes — foi o que travou comigo:

  • Nome já ocupado no PyPI → escolha outro nome de distribuição (ele pode ser diferente do nome que você usa no import) e ajuste o version("nome") para bater.
  • npm respondendo 404 ou ENEEDAUTH → quase sempre é o trusted publisher que ficou sem configurar (marcar só "disallow tokens" não basta).
  • npm ignorando o crachá (OIDC) → npm velho demais; force o npm install -g npm@latest no workflow.
  • Pacote novo no npm → faça um npm publish manual só para criar o pacote, e só depois configure o trusted publisher.
  • Nenhuma autenticação acontece → provavelmente faltou a permissão id-token: write no job.

Lançando novas versões

Do segundo release em diante é sempre o mesmo, para os dois pacotes, sem nunca mais tocar em token:

make version v=X.Y.Z    # sobe a versao no pyproject.toml / package.json
git tag vX.Y.Z
git push origin main vX.Y.Z

O CI valida a tag contra a versão do projeto, roda os testes, builda e publica via OIDC.

Conclusão

Trusted Publishing via OIDC troca "um segredo guardado que pode vazar" por "uma identidade efêmera e verificável". Depois da configuração inicial — pending publisher no PyPI e a conexão de trusted publisher + disallow tokens no npm — publicar vira só empurrar uma tag. Menos secrets para gerenciar, menos superfície de ataque, e o release fica auditável de ponta a ponta. Vale cada minuto da configuração. 🔐✨

Links úteis: PyPI Trusted Publishers · npm Trusted Publishers · OIDC no GitHub Actions · Deprecação de tokens 2FA-bypass do npm