Introdução

A ideia deste workshop é explorarmos automações para facilitar a experiência do desenvolvimento utilizando como ambiente o Github Actions.

O objetivo final é chegar com conhecimento o suficiente para criação de um sistema de CI funcional e, se tudo der certo, falaremos um pouco sobre a entrega contínua.

CI/CDs

O CI/CD é um dos pilares da cultura DevOps.

Para garantir que os times consigam entregar mais e com mais frequência, a prática do CI/CD é fundamental.

Essa é uma sigla muito utilizada nos dias de hoje e, nesta parte, iremos discutir um pouco sobre o que ela significa e suas implicações no dia a dia.

Integração contínua (Continuous Integration)

A integração contínua é uma prática onde os desenvolvedores sobem alterações constantes no repositório. A cada alteração, há um processo definido de build e teste automatizado para garantir que as alterações não quebraram nada.

Um dos maiores benefícios do CI é garantir que o repositório está sempre o mais atualizado possível e sempre com corretude.

Entrega Contínua (Continuous Delivery)

A etapa de entrega contínua é o passo seguinte ao CI. Consiste em ter automações para a entrega do novo código nos ambientes: seja de dev, tst ou prod.

A automação do processo de entrega de código para os ambientes finais, quando feito como código, serve também como uma documentação e pode ser versionada no próprio repositório. Além disso, acaba reduzindo a carga da pessoa desenvolvedora em dois níveis:

  • carga cognitiva: a pessoa desenvolvedora não precisa ficar redescobrindo comandos arcanos para realizar o deploy. Idealmente o processo é iniciado com alguns cliques (ou automaticamente).
  • carga processual: a máquina da pessoa desenvolvedora não fica onerada com o processamento de construção do artefato para ser implantado já que todo esse processamento ocorrerá em um servidor especifico para essa tarefa.

Até pouco tempo atrás, deploys consistiam em copiar o build local para um servidor via FTP. Olha só como avançamos!!

Diferença entre CDs?

Existe também a prática de Implantação Contínua (Continuous Deployment). Enquanto o deploy para produção é feito de forma manual na Entrega Contínua, com a Implantação Contínua deploy para produção/cliente final, é feito de forma automática e apoiado por uso de feature flags.

Github Actions

Apesar de existir uma infinitude de ferramentas para criação de sistemas de CI/CD, a que utilizaremos aqui é o Github Actions.

Alguns motivos são:

  • integração com Github
  • flexibilidade na criação dos sistemas
  • comunidade

Workflows

Para definirmos o que é um workflow, podemos buscar a definição da própria documentação:

Um fluxo de trabalho é um processo automatizado configurável que executa um ou mais trabalhos. Os fluxos de trabalho são definidos por um arquivo YAML verificado no seu repositório e será executado quando acionado por um evento no repositório, ou eles podem ser acionados manualmente ou de acordo com um cronograma definido.

Ou seja, são automações definidas via yaml que são configuradas de acordo com cada necessidade.

Um workflow é composto por pelo menos um gatilho e pelo menos um job.

Vamos dar uma olhada num workflow simples.

name: workshop
on: [push]
jobs:
  hello-world:
    runs-on: ubuntu-latest
    steps:
      run: echo "Hello World"

Neste workflow, definimos:

  • name: nome do workflow
  • on: gatilho que acionará a automação
  • jobs: uma lista de um ou mais processos
  • hello-world: o nome do nosso job
  • runs-on: definimos o runner
  • steps: as etapas que compõem o nosso job

Apesar de simples, já tem bastante coisa envolvida.

Nessa parte, vamos nos aprofundar um pouco mais em cada um desses atributos.

Executores

Mas fica a pergunta: onde vai rodar isso tudo?

Bem, existem duas opções para execução dos nossos workflow:

  • provisionados pelo github
  • autoprovisionado

Se reparar no exemplo que acabamos de ver, o job tem um parâmetro runs-on para indicação de qual é o executor a ser utilizado.

O que é?

De forma bem resumida, um executor (ou runner), é um ambiente efêmero cuja única função é executar um job definido no nosso workflow.

Na introdução da estrutura de um workflow, comentei que eles podem conter mais de um job e cada um deles deve ter uma definição de qual runner é necessário.

Que tal um exemplo mais prático? Imagine que você está desenvolvendo uma aplicação Desktop e precisa garantir o suporte e a compilação de 3 ambientes: Linux, Windows e Mac. Como cada job tem um executor próprio, você pode definir os 3 jobs de compilação no mesmo workflow.

github-hosted

Os runners provisionados pelo Github são os mais simples de serem utilizados. Se você não tem muito requisito de ambiente, o mais comum é o ubuntu-latest com o Linux.

Há também runners com sistemas Windows e macOS.

Você pode encontrar nesta tabela quais são as característica de cada uma das máquinas disponíveis para repositórios públicos e nesta aqui, para os repositórios privados.

Você certamente já ouviu a expressão "não existe almoço grátis". Almoço eu não sei, mas uma coisa é certa: não existem executores grátis. Pelo menos não ilimitado! Nessa página você consegue ver quais são os limites de cada um dos runners.

self-hosted

Se na sua realidade for importante a execução do seu workflow em um ambiente específico (sua cloud ou servidores on-premises), não se desespere!

O Github permite também que você utilize a sua infra própria para subir os executores! Exige um pouco mais de configuração e não abordaremos isso aqui, mas é possível!

Assim como nos outros, também existem regras e limites de uso. A documentação está aqui, caso tenha curiosidade!

Gatilhos

Como os workflows são acionados

Os gatilhos são definidos sempre no atributo on dos workflows. Existem diversos métodos para acionarmos os workflows, mas os principais são:

  • workflow_dispatch: acionamento manual. É possível definirmos entradas para parametrização da execução

  • workflow_call: acionado via outro workflow. Também é possível definirmos entradas.

  • schedule: acionado em um determinado padrão de horário.

  • push: acionado sempre que acontecer um push.

    Se quiser ignorar algum gatilho via push/pr, basta utilizar um [skip ci] na mensagem de um commit.

  • pull_request: acionado sempre que um pull request for criado, fechado, etc. Podemos especificar coisas como:

    • branches/branches-ignore: acionar apenas quando acontecer em uma determinada branch ou evitar a execução nela. No caso dos pull requests é verificada a branch alvo do PR.
    • paths/paths-ignore: acionar apenas quando acontecer alteração em um caminho ou ignorar se algum arquivo for alterado. Um bom uso para ignorar caminhos é evitar deploys de arquivos que não são parte do código fonte, por exemplo, o README.md de um projeto.

    O uso do paths/paths-ignore é mutuamente exclusivo. Para ignorar um caminho ao usar o paths, basta utilizar o prefixo de negação !.

    A inclusão de um caminho depois de uma exclusão, adiciona o caminho novamente. Ou seja, a preferência é sempre decrescente.

Exemplos

Acionamento manual

name: Aciona aqui
on: workflow_dispatch
# ...

cronjob

name: Roda todos os dias às 07h (UTC)
on:
  schedule:
    - cron: '0 7 * * *'
# ...

Deploy com push na main

name: Deploy da main
on:
  push:
    branches:
      - 'main'
# ...

Deploy apenas com alterações em código na main

name: Deploy na main com alteração de código
on:
  push:
    branches: [main]
    paths:
      - "src/**.js"
# ...

Referências

  1. Documentação oficial: Eventos que disparam fluxos de trabalho
  2. Documentação oficial: Ignorar execuções de fluxo de trabalho

Variáveis de ambiente

Assim como no ambiente de desenvolvimento, podemos definir variáveis para parametrizar o comportamento de alguns programas ou fluxos.

Um exemplo clássico é o uso da variável NODE_ENV para definir em qual ambiente o projeto será executado.

$ NODE_ENV=production npm run build
$ NODE_ENV=development npm run build

No ambiente de produção são feitas algumas otimizações que são dispensáveis no ambiente de desenvolvimento.

Escopo

Podemos definir uma variável em 3 níveis:

  • workflow
  • job
  • etapa

Para definir uma variável, basta definirmos um mapa env. Todos os elementos abaixo daquele escopo terão acesso à variável. Por esse motivo, é bom seguirmos uma das regras de ouro da segurança: quanto menor o escopo, melhor.

Exemplos

Definir versão do NodeJS via NVM

name: Instala a versão do NodeJS
on: # ...
env:
  NODEJS_VERSION: 22.3.0
jobs:
  install-node:
    runs-on: ubuntu-latest
    steps:
      # ... Instala o NVM
      - name: Define versão do NodeJS
        run: nvm use $NODEJS_VERSION

Saídas (outputs) e dependências

Às vezes precisamos passar informações de uma etapa para outra e até de um job para outro. Para resolver esse problema, podemos utilizar as saídas (outputs).

É importante notar que os outputs são apenas strings e ficam armazenados em um arquivo de saída do workflow. O caminho deste arquivo está disponível através da variável GITHUB_OUTPUT e este arquivo é compartilhado por todas as etapas de um job.

Para definir uma saída de um job, basta usar o atributo outputs. Para acessar, utilizamos o steps.<nome da etapa>.outputs.<nome da saída> ou needs.<nome do job>.outputs.<nome da saída>.

name: Job com uma saída
# ...

jobs:
  job1:
    runs-on: ubuntu-latest
    outputs:
      olamundo: ${{ steps.etapa1.outputs.ola }}
    steps:
      - id: etapa1
        run: echo 'ola=mundo' >> $GITHUB_OUTPUT
  job2:
    runs-on: ubuntu-latest
    needs: job1
    steps:
      - env:
           SAIDA1: ${{ needs.job1.outputs.ola }}
        run: echo "Olá $SAIDA1"

Observe que na linha needs: job1 definimos que o job2 só será executado após uma execução bem sucedida do job1. Caso essa dependência não fosse definida, o job2 seria executado de forma paralela ao job1 e o comportamento do nosso workflow poderia não ser o desejado.

Perceba que na definição do output, utilizamos a keyword run para executar um comando dentro do runner. Para quem não é muito chegado no bash, utilizamos um print com o comando echo e redirecionamos a saída para o arquivo presente em $GITHUB_OUTPUT.

Alguns casos de uso

Gerar tag do projeto utilizando a versão do package.json

# ...
    steps:
    # ... Checkout e configuração de credenciais do git
      - name: Atualiza versão
        run: npm version minor
      - name: Versão do projeto
        id: version
        run: echo version=$(npm pkg get version | sed 's/"//g') >> $GITHUB_OUTPUT
      - name: Cria tag com a nova versão
        env:
          VERSION= ${{ steps.version.outputs.version }}
        run: |
          git tag -a "v$VERSION"
          git push --tags

Referências

  1. Documentação oficial: Definindo saídas para trabalhos

Segredos e variáveis

Um dos primeiros conceitos que encontramos ao iniciar no mundo da programação é o uso de variáveis. Isso se deve ao fato de que às vezes o nosso código precisa receber alguma entrada do usuário, além de facilitar a manutenção e leitura do código.

Com os workflows não é diferente. Para isso, existem os secrets e as variables.

Além disso, o Github Actions possui uma abstração chamada environments para facilitar o controle de secrets e variables em diferentes ambientes como dev, tst e prod, por exemplo.

secrets e variables

Um secret e uma variable são parâmetros que podem ser acessados pelo workflow e a principal diferença entre eles é que o secret, como o nome dá a entender, é secreto. Ou seja, depois de definido, apenas os workflows podem acessá-los. O próprio GHA vai ocultar os valores dos secrets se eles fossem exibidos no log. Se for um dado sensível, como uma credencial, crie um secret. Caso contrário, use uma variable.

Mas como fazemos para criar um secret? Vamos ver isso agora!

Níveis e criação dos parâmetros

Esses parâmetros podem ser definidos a nível de organização, repositório e ambientes. Se houver colisão de nomes, ou seja, um secret chamado SECRET_SECRETO_DO_JP a nível de organização e um outro chamado SECRET_SECRETO_DO_JP a nível do repositório, a preferência é sempre do secret do repositório! Se ainda houvesse um secret chamado SECRET_SECRETO_DO_JP a nível de ambiente, a preferência seria do ambiente!

Em resumo: quanto menor o escopo, maior a preferência.

Para a criação, basta ir na configuração do nível desejado:

  • repositório: aba de configurações do repositório > Segurança > Segredos e variáveis > Actions > Segredos / Variáveis > Novo segredo/variável
  • ambiente: aba de configurações do repositório > Ambientes > Selecione o ambiente > Segredos / Variáveis > Novo segredo/variável
  • organização: aba de configurações da organização > Segurança > Segredos e variáveis

Utilização

Os segredos e variáveis estão disponíveis através do objeto secrets e vars, respectivamente. Ou seja, para acessar o valor do secret SECRET_SECRETO_DO_JP, basta expandirmos o valor via ${{ secrets.SECRET_SECRETO_DO_JP }}.

Exemplos

Notificação de webhook

name: Notifica no slack
on: # ...
jobs:
  send-notification:
    runs-on: ubuntu-latest
    steps:
      - uses: slackapi/slack-github-action@v1.26.0
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Credenciais da AWS

name: Autentica credencial da AWS
on: # ...
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4.0.2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }}
          aws-region: ${{ vars.AWS_DEFAULT_REGION }}
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          role-duration-seconds: 3600

Etapas / Steps

Nos capítulos anteriores vimos alguns exemplos de workflows, mas passamos batido por um detalhe importante: apesar de sabermos que um job é composto por etapas, o que compõe uma etapa?

Podemos definir dois tipos de etapas:

  • comandos
  • ações (actions)

Comandos

Podemos indicar a execução de um comando através da palavra-chave run. Como vimos em alguns exemplos anteriores, ao utilizar o run, interagimos diretamente com uma shell e podemos inclusive fazer uma cadeia de comandos.

# ...
    steps:
      - name: Dá oi
        run: echo "Olá mundo!"
      - name: Pergunta se está tudo bem e se despede
        run: |
          echo "Tudo bem?"
          echo "Tchau!"

Parâmetros

A melhor maneira de passarmos parâmetros para os comandos é através das envs.

# ...
    steps:
      - name: Imprime BAR
        env:
          FOO = "BAR"
        run: echo "$FOO"

Actions

Para tarefas mais complexas, podemos utilizar actions. Elas são componentes que permitem a repetição de atividades e reduzem a complexidade do seu workflow.

Para invocarmos uma action, utilizamos a palavra-chave uses.

Versão

Imagina só esse cenário: na semana passada o seu time gerou uma release utilizando o pipeline. Quando chegou a sua vez de tirar a release, você se deparou com um erro. O mantenedor de uma das actions que você utiliza aproveitou pra trabalhar nela no tempo livre do fim de semana e acabou subindo uma alteração que quebrou a compatibilidade com o seu workflow.

COMO ASSIM????

É. Isso pode acontecer. Esse workshop é pra mostrar como o Github Actions é legal e útil, não que sua relação com ele vai ser perfeita e mil maravilhas.

Mas calma, tem uma maneira da gente se proteger desse tipo de problema. E não é uma surpresa pra ninguém que tenha passado pelo Inferno de dependências.

E se a gente especificar exatamente qual a versão da action estamos interessado no nosso workflow? Para fazer isso, basta passarmos uma referência via @ depois do nome da action. Essa referência pode ser uma tag ou, caso o criador da action não tenha sua confiança, a chave SHA do commit para garantir mais segurança.

# ...
       uses: uma/actionmuitomaneira@<SHA/tag>

Parâmetros

Para passarmos parâmetros para as actions, geralmente utilizamos a palavra-chave with.

# ...
    steps:
      - name: Clona o repositório e vai pra branch principal
        uses: actions/checkout@v4
        with:
          ref: main

Actions úteis

Abaixo destaco algumas actions que acho interessantes.

  1. actions/checkout: permite clonar o repositório e fazer o checkout em uma revisão específica.
  2. actions/setup-node: instala a versão especificada do node.
  3. googleapis/release-please-action: Criação automática de releases utilizando Conventional Commits.
  4. aws-actions/configure-aws-credentials: configura uma credencial da AWS para utilização de serviços como aws-cli e outros.
  5. SonarSource/sonarcloud-github-action: integração com o SonarQube.
  6. softprops/action-gh-release: criação automática de release.
  7. actions/cache: permite o cache de artefatos para redução do tempo de execução do workflow
  8. actions/upload-artifact: realiza upload de arquivos como CHANGELOG.md ou arquivos de cobertura de testes do código.
  9. actions/install-nix: instala nix no runner.
  10. actions/docker-setup-buildx: instala docker no runner.

Além dessas, há uma infinitude de actions que você pode buscar no Github Marketplace - Actions e filtrar por categorias.

Actions extremamente específicas pra caramba

Caso você tenha buscado e não encontrou uma action que resolva o seu problema, não se desespere! Assim como você não encontrou aquela pizzaria que faria aquela pizza de nutella e abacaxi ou outras combinações altamente questionáveis, é sempre possível partirmos para uma solução caseira.

Existem 3 tipos de actions a serem criadas:

  • container Docker
  • JavaScript
  • ações compostas

Não vamos abordar isso no workshop, mas você sempre pode contar com a documentação oficial para aprofundar nos tópicos discutidos aqui!

Mão na massa

Beleza. Tudo lindo, tudo bacana. Mas vamos pra prática?

Requisitos

  • Conta no Github: afinal de contas é a plataforma que estamos falando desde o início
    • Criação de repositório
    • Criação de um secret para armazenar a credencial do NPM
    • Criação de um Personal Access Token (PAT) para utilização no workflow
  • Conta no NPM: onde publicaremos nossa biblioteca
    • Criação de uma credencial para publicação da biblioteca

Pessoalmente gosto bastante de CLIs e recomendo também a gh-cli. É opcional, mas facilita bastante em algumas etapas.

Agenda

Nessa etapa do workshop, vamos criar workflows para resolver dois pontos importantíssimos da etapa de desenvolvimento:

  • análise de PRs
  • criação de release

Na etapa de análise de PR, estamos interessados em verificar se os testes estão passando. Você pode (e deve) ser mais criterioso do que o nosso exemplo. Lembre-se que contexto é tudo!

Já na criação da release, vamos assumir um mundo perfeito: toda alteração que chega na branch é uma release nova, afinal tudo passou pelo crivo das nossas análises dos PRs.

Vamos utilizar como base um projeto em node, mas você é livre para escolher a linguagem/ecossistema que preferir.

Lembre de atualizar as actions para refletir a sua escolha.

Criando um projeto

Essa etapa é a mais tranquila. É tipo aquela prova do colégio que o professor dava meio ponto só por preencher o cabeçalho.

Criação do repositório

Vamos criar um repositório vazio. O nosso projeto vai ser uma biblioteca responsável por (pasme!!!) dizer um 'Olá' para o mundo!!

Com a gh-cli, você pode criar o repositório com o comando

 $ gh repo create jpedrodelacerda/workshop-gha --clone

Ele também clona o repositório pra você.

Criação do projeto

Depois do repositório clonado, vamos iniciar o projeto usando o npm init.

Você pode utilizar também pnpm ou yarn se preferir, mas lembre de alterar as actions.

$ cd workshop-gha
$ npm init -y

Além de criar o projeto, é sempre bom ter um arquivo README.mdno projeto.

$ echo "# Workshop GHA" >> README.md

Pronto. Temos nosso repositório iniciado.

Desenvolvedores responsáveis

Como bons desenvolvedores que somos, não podemos esquecer dos testes!

Para isso, utilizaremos o jest.

$ npm install --save-dev jest

Lembre de commitar e enviar

$ git add README.md package.json package-lock.json
$ git commit -m "chore: initial commit"
$ git push

Afinal, de que adianta se as alterações ficam só na nossa máquina?

Automatizando etapas do PR

Nessa etapa, o objetivo é desenvolver um workflow que será executado para automatizar os testes de um PR. Dessa forma, conseguimos reduzir o ruído na hora da revisão do código. Afinal, não faz muito sentido revisar um PR em que os testes estão quebrados.

Criação do primeiro workflow

Para criar um workflow, precisamos de um arquivo em .github/workflows/.

O primeiro passo vai ser definir quais são os gatilhos para o nosso workflow.

Queremos que a checagem sempre seja executada quando um PR é criado.

Podemos especificar, por exemplo, que só execute os testes nos PRs que tenham como alvo uma branch com determinado nome combinando pull_request o atributo e branches. Mas esse não é o nosso caso!

name: PR Checker
on:
  pull_request:

Isso é suficiente? Sim, mas podemos ser ainda mais específicos.

Faz sentido executar testes em PRs que alteram apenas arquivos de documentação? Se eu fiz uma alteração no README.md, tem motivo executarmos testes. Especificamos isso utilizando paths.

name: PR Checker
on:
  pull_request:
    paths:
      - '**.js'

Checkout

O próximo passo é garantir que o runner tem acesso ao repositório.

name: PR Checker
on:
  pull_request:
    paths:
      - '**.js'

jobs:
  run-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

Configuração do Node

Agora que temos acesso ao repositório, precisamos garantir que temos o node instalado.

name: PR Checker
on:
  pull_request:
    paths:
      - '**.js'

jobs:
  run-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repo
        uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22.x

Execução dos testes

Para execução dos testes, precisamos instalar as dependências e executar o script.

name: PR Checker
on:
  pull_request:
    paths:
      - '**.js'

jobs:
  run-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Prepare | Checkout repo
        uses: actions/checkout@v4
      - name: Prepare | Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 22.x
      - name: Prepare | Install dependencies
        run: npm i
      - name: Run tests
        run: npm run test

Cache

O workflow está pronto e funcional! Porém, podemos fazer uma otimização boa nele. Ao invés de baixar todas as dependências sempre que executar, que tal criarmos um cache das dependências e, caso nenhuma delas tenha sido atualizadas, utilizar o que deixamos nele?

Para isso, utilizaremos a actions/cache.

name: PR Checker
on:
  pull_request:
    paths:
      - '**.js'

jobs:
  run-tests:
    runs-on: ubuntu-latest
    steps:
      - name: Prepare | Checkout repo
        uses: actions/checkout@v4
      - name: Prepare | Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 22.x

      - name: Prepare | Get npm cache directory
        id: npm-cache
        run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT

      - name: Prepare | Cache dependencies
        id: cache
        uses: actions/cache@v4
        with:
          path: ${{ steps.npm-cache.outputs.dir }}
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Prepare | Install dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm i

      - name: Run tests
        run: npm run test

Adicionamos a actions/cache definindo os seguintes parâmetros:

  • path: diretório que deve ser armazenado no cache
  • key: um identificador para o cache. Neste caso utilizamos uma concatenação do sistema e uma hash do package-lock.json. Toda vez que houver uma alteração no sistema utilizado ou no arquivo de dependências, ele utilizará outro cache.

    A expressão hashFiles e outras estão documentadas aqui.

  • restore-keys: conjunto de padrões do cache. Caso não tenha um match exato, ele vai tentar restaurar o cache mais próximo.

    Importante: a instalação de dependências só deixa de acontecer se houver um match exato da chave!

    Se não encontrar, ainda vai executar a instalação das dependências. Mas ainda assim, espera-se que seja num tempo menor, pois atualizaria apenas o necessário.

Perceba também que adicionamos uma condicional na etapa de instalação de dependências: só execute a instalação das dependências caso o cache não tenha encontrado uma

Múltiplas versões em diferentes arquiteturas

Você já reparou que em alguns projetos estão disponíveis várias versões de um executável? Você precisa decidir qual plataforma vai usar, qual sistema, etc.

Imagina ter que repetir o tamanho do workflow pra dar suporte pra isso!!

Mas calma, será que realmente precisamos fazer essa duplicação? Existe alguma solução para permitir suporte para todas as combinações de forma simples?

Se nossa biblioteca tivesse suporte para outras versões do Node e outros sistemas, poderíamos executar a nossa ação em cada uma das versões através das matrizes.

name: PR Checker
on:
  pull_request:
    paths:
      - '**.js'

jobs:
  run-tests:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: [18, 20, 22]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Prepare | Checkout repo
        uses: actions/checkout@v4
      - name: Prepare | Setup node ${{ matrix.node }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}.x

      - name: Prepare | Get npm cache directory
        id: npm-cache
        run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT

      - name: Prepare | Cache dependencies
        id: cache
        uses: actions/cache@v4
        with:
          path: ${{ steps.npm-cache.outputs.dir }}
          key: ${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-${{ matrix.node }}

      - name: Prepare | Install dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm i

      - name: Run tests
        run: npm run test

Atenção: caso você precise dar suporte a mais de uma arquitetura de processadores, é preciso também se atentar para o cache, sendo necessário mudar a chave para garantir compatibilidade do sistema.

Assim como na etapa anterior, se houver uma etapa específica de uma plataforma ou versão, você pode utilizar uma condicional para ignorá-la ou não.

Finalizando

Agora vamos para a publicação!!

Publicando uma release

Como estamos trabalhando com uma biblioteca, a publicação da release vai ser no npm. Para isso, basta ter uma conta registrada no site. Queremos também gerar um texto contendo as mudanças que ocorreram desde a última release.

Nesta etapa não nos preocuparemos com teste. Assumimos no nosso mundo ideal lindo e maravilhoso que toda alteração já passou previamente pelo teste na etapa de PR. Caso seja do seu interesse, você pode definir os jobs que utilizamos na etapa anterior para executar testes antes da publicação.

Começando pelo começo

Vamos definir um nome e o gatilho que queremos para o nosso workflow no arquivo ./github/workflows/release.yaml.

Definimos anteriormente que queremos publicar uma nova versão sempre que chegar alguma alteração na branch principal. De forma muito parecida com o gatilho do pr-checker, também só estaremos interessados se essas alterações aconteceram no código fonte do projeto ou arquivos que podem indicar a criação de uma release nova.

name: Create release and publish
on:
  push:
    branches:
      - main
    paths:
      - '**.js'
      - 'package.json'
      - 'CHANGELOG.md'

Pode ser interessante utilizar um gatilho manual na etapa de validação do seu workflow com um gatilho workflow_dispatch, mas lembre-se de retirá-lo quando o workflow estiver funcionando normalmente.

Processo da release

Utilizaremos o conventional commit para padronização das mensagens de commit.

Se quiser dar uma lida com calma, a documentação está aqui.

Em resumo, nossas mensagens terão o seguinte formato:

<tipo>[escopo opcional]: <descrição>

Caso tenha uma quebra de compatibilidade, podemos indicar isso para o sistema utilizando um ponto de exclamação: <tipo>!: <descrição>. Isso fará com que a major version seja atualizada.

Escolhemos o conventional commits pois utilizaremos a action googleapis/release-please.

Eu falei que tudo que chegasse na main seria uma release, né? Mas não vai ser chegou e publicou. O processo vai ser com as seguintes etapas:

  1. criação de PR apontando para main Desenvolveremos nossa feature em uma branch de vida curta. Assim que finalizarmos o desenvolvimento dela, abriremos um PR para a branch principal.
  2. merge na main Acionará o workflow da release.
  3. a action irá criar um novo PR da release Com a execução do workflow da release, um novo PR é criado. Até então nossa release não foi publicada.
  4. merge do PR da release Com esse merge, a action criará uma release do Github e executará o resto do nosso workflow, publicando o pacote no NPM.

Preparando a publicação

Antes de começar a fazer nosso workflow de release, vamos preparar o terreno!

Por padrão, o npm publica no repositório público. Caso você deseje fazer o deploy para outro repositório, pode configurar isso via publishConfig no package.json.

Vamos aproveitar e atualizar algumas informações:

  • name: vamos adicionar um escopo
  • author: informações pessoais.
  • license: eu vou utilizar a MIT ou Apache-2.0. Sinta-se livre para escolher a que preferir.
  • publishConfig.access: para definir que o pacote é publico. Pacotes privados no NPM requerem pagamento. Caso não queira configurar isso no package.json, você pode passar a flag --access public no comando de publicação do workflow.
{
  "name": "@jpedrodelacerda/workshop-gha-n7ff5",
  "author": "João Lacerda <dev@jpedrodelacerda.com> (https://ddb.jpedrodelacerda.com/)",
  "license": "(MIT OR Apache-2.0)",
  "publishConfig": {
    "access": "public"
  }
  ...
}

Autenticação

Mesmo com um escopo, o que impede alguém de publicar uma nova versão do meu pacote? Ou melhor, como o NPM consegue confiar que, ao publicar uma versão, sou eu mesmo?

Para fazer a autenticação, precisamos de algum identificador gerado pelo NPM. Esse identificador é secreto e deve ser utilizado com muito cuidado.

Se você pensou em armazenar isso em um secret do repositório, pensou certo!

Depois de logar na conta do NPM, clique no seu perfil e procure por tokens de acesso. Vamos gerar um novo token de acesso granular. Coloquei o nome no meu de workshop-gha. (Que original, não?) O tempo de vida do token fica a seu critério. Quanto menor, melhor. A não ser que vá utilizar para outros projetos.

Não se esqueça de dar permissão de leitura e escrita (read and write). Por desencargo de consciência, vou definir também o escopo apenas para o @jpedrodelacerda.

Depois de criado, vá nas configurações do seu projeto no Github e adicione o secret NPM_TOKEN com o token criado.

Se estiver com a gh-cli, você pode criar o secret do repositório com o seguinte comando:

$ gh secret set NPM_TOKEN

Criando uma release

Para adicionar a release, precisamos garantir algumas permissões para o workflow. Vamos aproveitar e adicionar a action também.

Ações utilizando o token padrão do workflow não geram novos acionamentos para evitar loops indesejados. Portanto, vamos criar um Personal Access Token para que tanto o PR quanto o commit da release possam acionar nosso workflow.

Se tiver logado com a gh-cli e quiser reutilizar o token que está configurado para ela, você pode executar o seguinte comando:

$ gh secret set PAT_TOKEN -b $(gh auth token)

Para mais informações sobre as permissões, veja a documentação da action.

name: Create release and publish
on:
  push:
    branches:
      - main
    paths:
      - '**.js'
      - 'CHANGELOG.md'
      - 'package.json'

jobs:
  release-please:
    permissions:
      contents: write # Permitir a criação do commit de release
      pull-requests: write # Permitir a criação do PR de release

    runs-on: ubuntu-latest
    steps:
      - name: Prepare | Create github release
        uses: googleapis/release-please-action@v4
        with:
          token: ${{ secrets.PAT_TOKEN }}
          release-type: node

Publicação da biblioteca

Para publicar um pacote no NPM, é necessário ter uma conta registrada e precisamos também atualizar o package.json.

name: Create release and publish
on:
  push:
    branches:
      - main
    paths:
      - '**.js'
      - 'CHANGELOG.md'
      - 'package.json'

jobs:
  release-please:
    permissions:
      contents: write # Permitir a criação do commit de release
      pull-requests: write # Permitir a criação do PR de release

    runs-on: ubuntu-latest
    steps:
      - name: Prepare | Create github release
        uses: googleapis/release-please-action@v4
        id: release
        with:
          release-type: node

      - name: Prepare | Checkout repo
        if: ${{ steps.release.outputs.release_created }}
        uses: actions/checkout@v4

      - name: Prepare | Configure Node+NPM
        if: ${{ steps.release.outputs.release_created }}
        uses: actions/setup-node@v4
        with:
          version: 22.x
          registry-url: 'https://registry.npmjs.org'

      - name: Prepare | Install deps
        if: ${{ steps.release.outputs.release_created }}
        run: npm ci

      - name: Publish | Publish package to NPM
        run: npm publish
        if: ${{ steps.release.outputs.releases_created }}
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Perceba que só publicamos se nossa release foi criada.

Não esqueça de commitar nossas alterações e vamos validar nosso trabalho!!

Validando nosso CI

Com tudo pronto, vamos criar nossa primeira release?

Primeira saudação

Antes de tudo, vamos criar uma branch nova pra essa nossa feature?

git branch -b feat/add-hello

Precisamos adicionar um código para nossa biblioteca.

  • index.js:

    const { hello } = require("./src/hello");
    
    console.log(hello());
    
    module.exports = { hello };
    
  • src/hello.js:

    const hello = () => {
        return "Olá mundo!";
    };
    
    module.exports = { hello };
    

Vamos subir nossa modificação e criar nosso primeiro PR.

$ git add index.js src/hello.js
$ git commit -m 'feat: add greeting function'
$ git push --set-upstream origin feat/add-hello

Os testes falharam. Essas falhas de pipeline incomodam, né? Pois bem, vamos corrigir isso tudo!

Testes

Agora com o teste, vamos atualizar o script de testes no package.json:

{
  ...
  "scripts": {
    "test": "jest"
  }
  ...
}

Subindo a alteração!!

$ git add package.json
$ git commit -m 'chore: use `jest` to run tests'
$ git push

Ainda está quebrado. Mas temos progresso! O erro que está dando agora é simples de resolver! Vamos adicionar um teste em src/hello.test.js para deixar o jest feliz!

const { hello } = require("./hello");

test("Retorna 'Olá mundo!'", () => {
    expect(hello()).toBe("Olá mundo!");
});
$ git add src/hello.test.js
$ git commit -m 'test: add `hello.test.js`'
$ git push

Publicando

Bem, agora que nosso PR está testado e aprovado, vamos mergear!

Para mergear, vamos utilizar o método do squash and merge, recomendado pelo googleapis/release-please.

Pronto! Agora que nosso workflow rodou, foi criado um novo PR de release. É só mergearmos e correr pro abraço!

Se tudo der certo, você acaba de publicar um novo pacote no NPM! Parabéns!!

Próximos passos

Apesar de termos completado a publicação de nossa valiosíssima biblioteca, esse não precisa ser o final da sua jornada!

O Github Actions tem uma infinidade tanto de aplicações como suporte. Abaixo vou mostrar (bem superficialmente) algumas.

Testcontainers

Você precisa fazer testes de integração com um serviço da AWS? Quer testar sua conexão com o banco? Nada tema!!

A ideia do Testcontainers é facilitar o trabalho de subir containers descartáveis para seus testes.

O projeto possui vários módulos pra deixar ainda mais tranquila a configuração. Procurou por um módulo que não existe? Não tem problema! Você pode especificar a imagem que precisa.

Quer um ecossistema mais completo e AWS é algo importante pra ti? Talvez as imagens do localstack possam te ajudar.

Teste local

Tá com vergonha de subir uma alteração e quebrar alguma coisa? Não precisa disso, mas já que tá rolando, que tal rodar seu workflow local?

Gostou da ideia?

É pra isso que o act tenta resolver. Com suporte a diversos eventos pra simular, ele vai subir containers para que você consiga validar localmente seu workflow!