GitHub Packages e GitHub Actions - Distribuindo contextos delimitados

O GitHub Packages foi a peça que usei para distribuir as capacidades de negócio do balta em nosso projeto novo, na forma de pacotes NuGet privados. Neste artigo, mostro a estratégia por trás dessa decisão e o passo a passo para publicar e consumir esses pacotes com GitHub Actions.

Contextos delimitados no novo projeto

O primeiro entendimento que fizemos foi sobre quais contextos delimitados teríamos no novo negócio. Basicamente, analisamos as áreas que já existiam e separamos tudo em capacidades de negócio pequenas e simples.

Como resultado, chegamos aos seguintes contextos:

  • Conteúdo.
  • Alunos.
  • Assinaturas.

Esses são os primeiros recortes, e outros virão à medida que o negócio crescer. Por enquanto, porém, quanto mais simples, melhor.

Regras de negócio e testes de unidade

O ponto principal de todo sistema são as regras de negócio e, consequentemente, os testes de unidade. Se você organiza tudo e mantém a regra na aplicação, mas não executa testes automatizados, está jogando metade dos benefícios no lixo.

Por isso, a premissa era manter os processos centralizados. Hoje temos integrações com gateways de pagamento, com o site e, futuramente, com o App.

Todos esses canais devem seguir o mesmo fluxo, o mesmo processo e as mesmas regras. Logo, concentramos todas as interfaces (contratos) e fluxos no Core da aplicação, separados nos contextos que mencionei.

Se você já fez os cursos Modelando Domínios Ricos e Refatorando para Testes de unidade, sabe quanto eu valorizo a orientação a objetos na modelagem. Inclusive, costumo brincar que o Core precisa funcionar só com os objetos do contexto, sem depender de API ou de dados.

Com esses objetos funcionando, basta persistir o estado em um banco e expor tudo via API. Gosto de pensar assim.

Mais adiante, é justamente esse Core que vamos empacotar e publicar no GitHub Packages.

Múltiplas arquiteturas

Outro ponto importante é que os contextos nem sempre seguem o mesmo padrão, porque agem de forma independente. Por exemplo, o contexto de conteúdos é extremamente voltado para leitura, então não faz sentido ter nele uma estrutura complexa de validação.

Nesse caso, temos apenas repositórios expondo informações. Já o contexto de assinaturas precisa de uma validação muito mais firme, porque envolve pagamentos, dados sensíveis e muitos testes de unidade.

Ou seja, one size doesn't fit all. Em bom português, arquitetura não tem bala de prata.

Por que distribuir os contextos

Mas por que separar um domínio em diversos subdomínios e contextos delimitados? A seguir, listo os motivos que pesaram na nossa decisão.

Quanto menor, melhor

Um componente pequeno é fácil de mudar ou até de jogar fora. Além disso, uma única pessoa consegue gerenciá-lo.

Fácil entendimento

Componentes menores também são mais fáceis de entender. Mesmo dentro do meu negócio, há processos de marketing, por exemplo, que me tomam tempo.

Como funciona a sinalização de uma nova assinatura para a ferramenta de marketing? E como funciona o processo que coloca um aluno em um funil de vendas?

Esses processos podem ser simples quando tratados individualmente, mas ficam complexos quando convivem muito juntos. Assim, a ideia de um componente é executar o processo com o mínimo de esforço e informação, para que outros sistemas também possam usá-lo.

Com o GitHub Packages, esse componente vira uma dependência versionada que qualquer sistema da empresa consegue instalar.

Fácil manutenção

Como disse Robert C. Martin, o Uncle Bob, criar um software para rodar uma vez é fácil, mas criar um software para ser mantido por muito tempo é difícil. Na teoria tudo é lindo, mas na prática a teoria é outra, e às vezes as coisas precisam acontecer para ontem.

Embora eu odeie essa situação e lute para evitá-la, não podemos ser hipócritas e dizer que estamos livres disso. Então, gambiarras vão acontecer, mas devem ser raras, e uma boa divisão permite refazer um componente com facilidade.

Por exemplo, na integração com um gateway de pagamentos, usamos o SDK em .NET que o próprio gateway fornece, o que foi uma péssima decisão. Ele não atende em diversos pontos e ainda depende de versões anteriores do .NET.

Porém, como esse componente é pequeno, já temos uma issue aberta para refazê-lo com o RestSharp, chamando a API do gateway diretamente. Juntando isso a uma boa modelagem e a testes de unidade, conseguimos uma manutenção tranquila.

Deploy independente com GitHub Packages

Outra vantagem de componentes independentes é a publicação. Se os Jobs de integração, o site, a API e o App consomem esses componentes, uma simples modificação neles pode gerar um novo deploy de tudo.

Por isso, separamos os contextos em repositórios próprios e os distribuímos como pacotes NuGet. Nesse cenário, trabalhamos com versionamento semântico para não quebrar o que já está rodando.

Dessa forma, conseguimos distribuir versões novas de forma independente. Por exemplo, algumas funcionalidades do Core só deveriam afetar as integrações naquele momento, enquanto o site precisava continuar como estava.

Com uma referência direta entre projetos de uma mesma solução, isso não aconteceria de forma tão amena.

Isso não é microsserviço

"Mas isso que você está falando são microsserviços?" Há algumas semelhanças, mas não são.

A ideia dos microsserviços também envolve componentes pequenos e independentes. No entanto, eles implicam uma série de outras peças que não temos, como IAM e múltiplos bancos de dados.

Inclusive, não temos a mínima necessidade de algo nesse nível hoje, e as justificativas seriam inválidas. Só a título de curiosidade, na época deste projeto todo o ecossistema do balta rodava no Azure por menos de US$ 150 por mês.

Ter cada contexto em uma API independente, com banco próprio, servidor de autenticação e API gateway, custaria pelo menos o dobro desse valor. Portanto, pacotes bem versionados no GitHub Packages entregam boa parte da independência sem esse custo.

O que é o GitHub Packages

A chave para distribuir os pacotes é o GitHub, que já usamos para gerenciar tudo relacionado a código. O GitHub Packages funciona como um NuGet privado, ao qual só os repositórios e as pessoas autorizadas da empresa têm acesso.

Além do NuGet, o serviço hospeda pacotes npm, Maven, RubyGems e imagens de contêiner. No nosso caso, temos uma organização no GitHub, e os repositórios ficam dentro dela.

Os contextos, por sua vez, nada mais são do que Class Libraries. Geramos o nupkg de cada uma e publicamos no GitHub Packages com GitHub Actions.

Iniciando um projeto

Para criar um pacote, basta gerar uma nova Class Library com um nome que faça sentido para o seu negócio.

dotnet new classlib -o MyContext

Não vou me ater ao código aqui. Então, abra o projeto, modifique o que desejar e certifique-se de que ele compila.

Gerando um pacote NuGet

Sempre que compilamos uma Class Library, o resultado é uma DLL, mas queremos gerar um pacote. Por isso, o primeiro passo é incluir algumas informações no arquivo .csproj.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>

    <PackageId>MyContext</PackageId>
    <Version>1.0.0</Version>
    <Authors>André Baltieri</Authors>
    <Company>balta.io</Company>
    <PackageDescription>My first package</PackageDescription>
    <RepositoryUrl>https://github.com/balta-io/MyContext</RepositoryUrl>
  </PropertyGroup>
</Project>

Essas informações vão dentro do pacote e ficam visíveis para quem for baixá-lo. Além disso, segundo a documentação do GitHub, o RepositoryUrl vincula o pacote publicado ao repositório correspondente.

Feito isso, podemos compilar e empacotar o projeto com os comandos abaixo.

dotnet build --configuration Release
dotnet pack --configuration Release

O comando pack gera um arquivo .nupkg na pasta bin/Release do projeto. A versão do pacote vem do .csproj que editamos.

Publicando o pacote no NuGet.org

Com o .nupkg gerado, você pode publicá-lo no NuGet.org e deixá-lo visível para todos que usam o Visual Studio, por exemplo. Para isso, basta se autenticar no site e fazer o upload do pacote.

Se preferir a linha de comando, use o comando abaixo.

dotnet nuget push "bin/Release/*.nupkg" -s nuget --skip-duplicate

O -s nuget indica que o destino é o NuGet.org. Já o --skip-duplicate evita erro quando aquela versão do pacote já existe.

Publicando no GitHub Packages

Quando criamos uma organização no GitHub, podemos usar pacotes privados, que ficam disponíveis na página inicial da organização. O próximo passo, então, é entender como o NuGet encontra esses pacotes.

Toda vez que executamos dotnet add package, o NuGet busca o pacote nas fontes definidas em um arquivo NuGet.Config global ou em um arquivo na raiz da aplicação. Nesse caso, o arquivo da raiz tem precedência sobre o global.

O arquivo global fica em %appdata%\NuGet\NuGet.Config no Windows. No Mac e no Linux, a documentação do NuGet indica ~/.nuget/NuGet/NuGet.Config ou ~/.config/NuGet/NuGet.Config, conforme a ferramenta.

Afinal, tudo o que o NuGet.org faz é armazenar os .nupkg e fornecer um JSON com os pacotes registrados. No GitHub Packages, a URL do feed da sua organização segue o formato abaixo.

https://nuget.pkg.github.com/<ORGANIZATION>/index.json

Autenticação com token de acesso pessoal

Se usamos -s nuget para enviar um pacote ao NuGet público, basta trocar o source para publicar no GitHub Packages. O único detalhe é a autenticação, já que só usuários da sua empresa podem publicar.

Então, para publicar direto da sua máquina, você precisa de um PAT (Personal Access Token), gerado no seu perfil em Developer Settings. Segundo a documentação, um token clássico precisa do escopo write:packages para publicar e de read:packages para instalar.

dotnet nuget push "bin/Release/*.nupkg" -k <TOKEN> -s https://nuget.pkg.github.com/<ORGANIZATION>/index.json --skip-duplicate

Feito isso, o pacote já aparece em https://github.com/orgs/<ORGANIZATION>/packages.

GitHub Packages e GitHub Actions

Não chegamos até aqui para publicar pacotes manualmente, não é? Queremos tudo automático, e para isso temos os GitHub Actions, que já usei para publicar aplicações ASP.NET no Azure.

Sabemos que precisamos executar dotnet build, dotnet pack e dotnet nuget push para publicar o pacote. Logo, nossa tarefa já está bem encaminhada.

O único desafio é o token de autenticação. Não podemos deixá-lo no arquivo .yml, senão todos com acesso ao repositório poderão vê-lo.

Por sorte, toda execução do GitHub Actions recebe um segredo chamado GITHUB_TOKEN. Ele é gerado automaticamente e dura apenas a execução do workflow, o que o torna perfeito para nós.

Workflow de publicação no GitHub Packages

Assim, podemos substituir nosso token por ${{ secrets.GITHUB_TOKEN }}, e o GitHub cuida do resto. Vamos então criar o arquivo .yml na pasta .github/workflows, que é a estrutura da qual os GitHub Actions dependem.

# .github/workflows/main.yml
name: Build and deploy MyContext Package

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@main

    - name: Set up .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: '5.0.x'
        source-url: https://nuget.pkg.github.com/<ORGANIZATION>/index.json
      env:
        NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    - name: Build with dotnet
      run: dotnet build --configuration Release

    - name: Create the Package
      run: dotnet pack --configuration Release

    - name: Publish
      run: dotnet nuget push "bin/Release/*.nupkg" -k ${{ secrets.GITHUB_TOKEN }} -s https://nuget.pkg.github.com/<ORGANIZATION>/index.json --skip-duplicate

Feito isso, basta enviar o código para a branch main, e o pacote chega ao GitHub Packages com deploy automatizado. A cada nova versão no .csproj, o workflow publica um pacote novo.

Vale uma observação para repositórios atuais. Se a organização restringir as permissões padrão do GITHUB_TOKEN, inclua no job a chave permissions com packages: write e contents: read.

Consumindo um pacote do GitHub Packages

Para consumir esse pacote na sua máquina, você precisa de permissão, afinal o pacote é privado. Se você está apenas estudando por este artigo, o caminho mais fácil é criar um NuGet.config na raiz do projeto consumidor.

Nesse arquivo, adicione o package source da sua organização e as credenciais. Você também vai precisar de um PAT com acesso de leitura aos pacotes.

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="github" value="https://nuget.pkg.github.com/<ORGANIZATION>/index.json" />
  </packageSources>
  <packageSourceCredentials>
    <github>
      <add key="Username" value="USERNAME" />
      <add key="ClearTextPassword" value="TOKEN" />
    </github>
  </packageSourceCredentials>
</configuration>

Se o projeto usa vários pacotes privados do GitHub Packages, recomendo copiar esse conteúdo para o arquivo de configuração global do NuGet. Assim, todos os projetos da máquina passam a enxergar o feed da organização.

No entanto, cuidado para não versionar esse arquivo com o token dentro. A documentação do NuGet desaconselha senhas em texto puro e sugere variáveis de ambiente como alternativa mais segura.

GitHub Actions que dependem do GitHub Packages

Por último, o desafio é usar pacotes privados do GitHub Packages em outras aplicações publicadas via GitHub Actions. Durante a publicação, o dotnet build executa um dotnet restore, que baixa as dependências da aplicação.

O problema é que a máquina criada pelo GitHub Actions não tem acesso aos pacotes privados. Por isso, o restore falha com um erro de permissão.

Então, assim como fizemos localmente, precisamos adicionar um package source antes do dotnet build. Porém, em vez de enviar um NuGet.config, vamos usar o comando dotnet nuget add source.

dotnet nuget add source https://nuget.pkg.github.com/<ORGANIZATION>/index.json -n github -u USERNAME -p PAT --store-password-in-clear-text

Fora do Windows, o NuGet não consegue criptografar a senha, por isso a opção --store-password-in-clear-text costuma ser necessária no runner Linux. Novamente, precisamos informar usuário e token.

Guardando usuário e token em secrets

Na configuração que usamos, o ${{ secrets.GITHUB_TOKEN }} não funcionava para ler pacotes de outro repositório. Por isso, a maneira mais segura de guardar usuário e token é usar os Secrets do repositório, em https://github.com/<ORGANIZATION>/<REPOSITORY>/settings/secrets/actions.

Os secrets funcionam como pares de chave e valor. Uma vez criados, você não consegue mais visualizá-los, apenas removê-los ou substituí-los.

Dessa forma, adicionamos mais um passo ao workflow de publicação da aplicação, que chamei de Add GitHub Packages Source.

- name: Set up .NET Core
  uses: actions/setup-dotnet@v1
  with:
    dotnet-version: '5.0.x'

- name: Add GitHub Packages Source
  run: dotnet nuget add source https://nuget.pkg.github.com/<ORGANIZATION>/index.json -n github -u ${{ secrets.PACKAGES_USER }} -p ${{ secrets.PACKAGES_TOKEN }} --store-password-in-clear-text

- name: Build with dotnet
  run: dotnet build --configuration Release

Pronto, você tem um deploy automatizado que consome um pacote privado do GitHub Packages, também publicado de forma automática.

Vale saber que a documentação atual do GitHub descreve uma alternativa. Em registros com permissões granulares, você adiciona o repositório consumidor em Manage Actions access, e o GITHUB_TOKEN passa a ler o pacote sem PAT.

Se você mantém vários repositórios com passos parecidos, vale também conhecer os workflows reutilizáveis no GitHub Actions. Com eles, o passo de autenticação fica centralizado em um único lugar.

Conclusão sobre o GitHub Packages

Podemos criar e distribuir aplicações grandes sem a necessidade de microsserviços. Para isso, basta criar pacotes privados, pequenos e independentes, e automatizar sua distribuição com GitHub Packages e GitHub Actions.

Mantenham as coisas o mais simples possível!

FAQ

O GitHub Packages é gratuito?

Pacotes públicos são gratuitos, enquanto os privados têm uma cota de armazenamento e transferência que depende do plano da conta. Acima da cota, o uso passa a ser cobrado.

Qual a URL do feed NuGet no GitHub Packages?

A URL segue o formato https://nuget.pkg.github.com/OWNER/index.json, em que OWNER é o usuário ou a organização. Ela serve tanto para publicar quanto para restaurar pacotes.

Quais escopos o PAT precisa ter?

Um token clássico precisa de write:packages para publicar e de read:packages para instalar pacotes privados. Evite escopos além do necessário.

Por que o restore falha no GitHub Actions?

O runner não conhece o feed privado nem tem credenciais para ele. A solução é adicionar o source com dotnet nuget add source antes do build.

Distribuir contextos como pacotes é o mesmo que microsserviços?

Não, porque os pacotes rodam dentro da mesma aplicação, sem bancos separados, IAM ou API gateway. Eles entregam independência de versão e deploy com custo muito menor.