Pular para o conteúdo principal

GitHub Actions: Fluxo e Persistência de Dados em Workflows

· Leitura de 12 minutos
Manu Magalhães
Engenheira de DevSecOps

Quando se trata de Github Actions, os dados não são persistentes por natureza, nem ficam disponíveis para todo o pipeline. Cada etapa (step) tem seu próprio processo, cada trabalho (job) tem seu próprio executor (runner). Por padrão, quaisquer dados que surjam em um trabalho terminam com ele.

Então, como podemos passar dados de um processo para outro, ou salvar dados para uma execução futura?

A resposta rápida e certeira é essa:

EstratégiaDadosEscopoPersistênciaDetalhesExemplo
envValoresTrabalho (interno)EfêmeroPropaga dados
entre etapas
no mesmo trabalho
Passar um booleano para determinar se a próxima estapa deve ser executada
outputsValoresFluxo de trabalho (interno)EfêmeroPropaga dados
entre trabalhos/etapas
no mesmo fluxo de trabalho
Passar um ID para o próximo trabalho
artefatosArquivosFluxo de trabalho (interno e externo)PersistentePropaga arquivos
entre trabalhos/fluxos de trabalho
Passar o build de um projeto para diferentes trabalhos de teste executados em paralelo

Preferência a dados que mudam frequentemente. Os arquivos ficam disponíveis para download após o término do fluxo de trabalho.
cacheArquivosFluxo de trabalho (interno e externo)PersistentePropaga arquivos
dentro e entre fluxos de trabalho
no mesmo repositório
Armazenar pacotes npm em cache para uso em diferentes execuções de fluxo de trabalho.

Destinado a arquivos que não mudam muito.

Para uma resposta mais completa, continue lendo. Todos os exemplos de fluxo de trabalho neste artigo ​​estão disponiveis em formato de arquivo aqui, junto com uma cópia dos respectivos logs editados (em inglês).

Como usar env

É muito simples transferir dados entre etapas: defina um par chave-valor (key/value) e grave-o no arquivo de ambiente GITHUB_ENV, usando a sintaxe apropriada para seu shell. Veja exemplos abaixo em bash e python:

Exibir código

/.github/workflows/using_env.yaml
    steps:
- name: Duas formas de definir variáveis de ambiente
# Aviso: nesta etapa, o input não foi sanitizado nem validado
shell: bash
run: |
# Não exibe a variável nos logs.
wikipedia_aleatorio_1=$(curl -L -X GET "https://en.wikipedia.org/api/rest_v1/page/random/summary" | jq .title)
echo "$wikipedia_aleatorio_1"
echo "ARTIGO_1=$wikipedia_aleatorio_1" >> "$GITHUB_ENV"
# 🐉 Exibe a variável nos logs: use apenas com dados que não sejam confidenciais!
wikipedia_aleatorio_2=$(curl -L -X GET "https://en.wikipedia.org/api/rest_v1/page/random/summary" | jq .title)
echo "ARTIGO_2=$wikipedia_aleatorio_2" | tee -a "$GITHUB_ENV"

- name: Definir variáveis de ambiente em python
shell: python
# se usar "write", use \n ao criar mais de uma variável
# com "print", \n não é necessário
run: |
from os import environ as env
with open(env.get('GITHUB_ENV', None), 'a') as ghenv:
ghenv.write("SUJEITO=Sun\n")
print("ESTADO=radiant", file=ghenv)
print("DIA=today", file=ghenv)

- name: 🛡️ Recuperando valores de forma segura
# observe que ARTIGO_1 não foi sanitizado ou validado, por isso, está vulnerável a ataques de injeção.
# A abordagem abaixo evita o problema ao passar ARTIGO_1 como argumento para o script.
# Também é possível renamear as variáveis
env:
QUEM: ${{ env.SUJEITO }}
QUE: ${{ env.ARTIGO_1 }}
QUANDO: ${{ env.DIA }}
run: |
echo "$QUEM leu sobre $QUE $QUANDO."

- name: 🐉 Recuperando valores de forma potencialmente vulnerável
# Esta abordagem é vulnerável a ataques de injeção!
# Use apenas se você tiver controle sobre o input
shell: bash
run: |
echo "${{ env.SUJEITO }} está ${{ env.ESTADO }} ${{ env.DIA }}."

Dica de debug

Para listar todas as variáveis ​​de ambiente disponíveis em um trabalho, adicione esta pequena etapa:

- run: env

Como usar outputs

Os outputs ficam disponíveis para todas as etapas do mesmo trabalho e para qualquer trabalho subsequente que precise deles. Outputs sempre são strings unicode.

E obviamente, trabalhos que dependem de um output não serão executados em paralelo com o trabalho que produz o output.

Mostrar código

Para simplificar, o output foi definido em bash, mas você pode usar o shell que preferir.

/.github/workflows/outputs-for-different-job.yaml
jobs:
definicao-de-output:
runs-on: ubuntu-latest
outputs: # Obrigatório: defina o output no trabalho para que fique disponível a outros trabalhos
nome: ${{ steps.valor-estatico.outputs.NOME }}
lugar: ${{ steps.valor-dinamico.outputs.LUGAR }}
steps:
- id: valor-estatico
run: |
echo "NOME=Marcela" >> "$GITHUB_OUTPUT"

- id: valor-dinamico
# Observe o use de jq -c para obter o valor como uma única linha
run: |
lugar=$(curl -H "Accept: application/json" https://randomuser.me/api/ | jq -c .results[].lugar)
echo "LUGAR=$lugar" > "$GITHUB_OUTPUT"

recuperacao-de--outputs:
runs-on: ubuntu-latest
needs: definicao-de-output
steps:
- name: boas-vindas
run: |
PAIS=$(echo $GEODATA | jq -r . | jq .PAIS)
echo "Olá $nome! $PAIS é/são um lindo país, divirta-se!"
env:
name: ${{needs.definicao-de-output.outputs.nome}}
GEODATA: ${{ needs.definicao-de-output.outputs.lugar }}

Embora seja recomendado usar env para passar dados entre etapas, outputs também pode ser usado. Isso é útil quando um valor é necessário tanto no trabalho atual quanto nos trabalhos a seguir.

Mostrar código

O exemplo anterior mostrou como usar outputs em diferentes trabalhos. Para usar um output no trabalho em que ele é definido, basta adicionar o código em destaque abaixo.

/.github/workflows/outputs-for-same-job.yaml
jobs:
definicao-de-output:
runs-on: ubuntu-latest
outputs:
name: ${{ steps.valor-estatico.outputs.NOME }}
lugar: ${{ steps.valor-dinamico.outputs.LUGAR }}
steps:
- id: valor-estatico
run: |
echo "NOME=Marcela" >> "$GITHUB_OUTPUT"
- name: Consumir o output no mesmo trabalho
run: |
echo "$NOME, $GEODATA é sua localização. Atualizamos seu fuso horário para GMT$OFFSET."
env:
NOME: ${{ steps.valor-estatico.outputs.NOME }}
# Use fromJSON() direto no env ao filtrar o valor do output ainda no env
# Veja mais sobre filtragem de objetos em
# https://docs.github.com/en/actions/learn-github-actions/expressions#object-filters
GEODATA: ${{ fromJSON(steps.valor-dinamico.outputs.LUGAR).country }}
OFFSET: ${{ fromJSON(steps.valor-dinamico.outputs.LUGAR).timezone.offset }}

(...)
Dica de debug
  • Um output individual deve ter no máximo 1 MB.
  • Todos os outputs combinados não devem exceder 50MB.

XP da vida Real

GITHUB_OUTPUT espera uma string de apenas uma linha. Se precisar de um output com várias linhas, atribua-as a uma variável e construa o output assim:

echo "NOME_DO_PAYLOAD<<EOF"$'\n'"$valor_do_payload"$'\n'EOF >> "$GITHUB_OUTPUT".

Como usar artefatos

A documentação diz: Use artefatos quando desejar salvar arquivos produzidos por um trabalho para visualização após o término da execução do fluxo de trabalho, como binários compilados ou logs de compilação. (tradução livre)

Subir artefatos

Você pode:

  • selecionar um ou vários arquivos para serem agrupados como um artefato.
  • usar curingas (wildcards), vários caminhos (paths) e padrões (patterns) de exclusão com a sintaxe de sempre do GitHub Actions.
  • definir um período de retenção para o artefato.
/.github/workflows/handle-artefacts.yaml
jobs:
upload:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fazer upload de logs
uses: actions/upload-artifact@v4
with:
name: todos-os-logs # nome do artefato
path: | # caminho dos arquivos incluídos no artefato
**/log*.txt # caminhos relativos têm como base o $GITHUB_WORKSPACE
retention-days: 1
if-no-files-found: error # forçar etapa a falhar se o conteúdo a ser incluído no artefato não for encontrado

Observe que o período máximo de retenção pode ser definido em nível de repositório, organização ou empresa. Há um máximo de 90 dias para repositórios públicos e de 400 dias para repositórios privados. Se você diminuir o período de retenção, terá mais espaço de graça ;)

Baixar artefatos

Para recuperar o artefato, você pode usar:

  • a UI do GitHub
  • a API do GitHub
  • o gh cli
  • a ação oficial actions/download-artifact, se precisar recuperar artefatos de maneira programática. A partir da v4, a ação permite baixar artefatos de diferentes fluxos de trabalho ou repositórios, desde que você forneça um token. (🛡️: é recomendado usar um aplicativo GitHub em vez de um PAT em projetos profissionais.)

Vamos ver como recuperar o artefato que criamos no exemplo anterior usando actions/download-artifact:

/.github/workflows/handle-artefacts.yaml
download:
runs-on: ubuntu-latest
needs: upload
steps:
- name: Baixar artefatos
id: baixar-artefatos
uses: actions/download-artifact@v4
with:
name: todos-os-logs # é o nome definido na etapa de upload

- name: Usar o artefato em um código python
shell: python
run: |
import os
from glob import glob
caminho_artefato = os.environ.get("CAMINHO", "")
glob_list = glob(caminho_artefato + "/*.txt")
for nome_arquivo in glob_list:
with open(nome_arquivo, "r", encoding="UTF-8") as f:
conteudo = f.read()
print(conteudo)
env:
CAMINHO: ${{ steps.baixar-artefatos.outputs.download-path }}

As ações de upload e download gerenciam o zip e o unzip dos artefatos automaticamente.

Apagar artefatos

Para excluir um artefato, você pode:

Como usar cache

🐉 Aviso de segurança

Não armazene informações confidenciais no cache (cuidado com arquivos de configuração contendo senhas!), pois o cache é acessível a qualquer pessoa com direito de criar um PR no repositório - isso inclui forks!

Este post já está grande demais. Vou tentar ser mais objetiva ao explicar cache:

  • Se você estiver usando executores auto-hospedados (self-hosted runners), a opção de armazenar o cache na sua própria infra só está disponível nos planos Enterprise.

  • A ação de cache requer um path para o cache e uma key. A key é usada para recuperar o cache e recriá-lo numa próxima vez.

  • Quando o trabalho termina, a ação injeta automaticamente uma etapa pós-cache que atualiza o cache caso novas dependências sejam instaladas.

  • Esta ação gerencia o cache de forma central. Isso significa que o cache fica disponível (e atualizável) a todos os trabalhos no mesmo repositório, e também a outros fluxos de trabalho.

  • Leia mais sobre estratégias de cache aqui (em inglês).

/.github/workflows/cache.yaml
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: |
**/requirements.txt

- name: Obter diretório de cache do pip
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT

- name: Controlar cache para dependências do python
uses: actions/cache@v3
id: cache
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

- name: Instalar dependências quando cache não for idêntico
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements.txt

Se quiser ver a prova do crime, veja os logs salvos (em inglês):

Curiosidade: você notou a função hashFiles usada na etapa acima?
É uma função fornecida pelo GitHub Actions para criar um valor hash exclusivo ligado a um arquivo. Quando o valor do hash não coincide, significa que as dependências foram alteradas, o que invalida o cache. Assim, as dependências são instaladas e o cache é atualizado em uma tarefa pós-cache.

Até mais! :)

Using cache

🐉 Security warning

Do not store sensitive information in the cache (beware of configuration files containing secrets), as the cache is accessible to anyone who can create a PR on the repository, even on forks.

Quando lidamos com dados estáveis que são​​ usados ​​repetidamente (por exemplo, dependências), podemos usar cache para melhorar o desempenho da pipeline.

No exemplo abaixo, vamos armazenar em cache as dependências pip. Repare que a etapa de cache foi colocada antes da etapa de instalação das dependências: a ideia é que a instalação só aconteça se o cache estiver desatualizado ou inexistente.

/.github/workflows/cache.yaml
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: |
**/requirements.txt

- name: Obter diretório de cache do pip
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT

- name: Controlar cache para dependências do python
uses: actions/cache@v3
id: cache
with:
# path: localização dos arquivos a serem armazenados em cache
path: ${{ steps.pip-cache.outputs.dir }}
# key: id único usado para recuperar e recriar o cache
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}

- name: Instalar dependências quando cache não for idêntico
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements.txt

Configurando o cache

Na primeira vez que o fluxo de trabalho é executado, obviamente o cache está vazio. Por isso, o output cache-hit (nativo da ação oficial actions/cache) retornará false. Assim, o fluxo de trabalho vai executar a etapa de instalação.
Confere nos logs! (em inglês)

Maaaas... Uma pequena mágica também acontece: uma etapa pós-cache, adicionada automaticamente pela ação action/cache no final da execução do trabalho, confere as chaves e adiciona os arquivos ao cache.

Recuperando o cache

Desde que nada tenha mudado no manifesto das dependências (requirements.txt), quando actions/cache for executado de novo com o mesmo caminho e a mesma chave, a ação vai encontrar um cache-hit e o fluxo de trabalho vai pular a etapa de instalação.
Confere nos logs! (em inglês)

Atualizando o cache

Você reparou na função hashFiles usada no argumento key?
Esta é uma função nativa do GitHub Actions que cria uma hash exclusiva baseada no caminho de um arquivo. Quando o valor da hash não dá match, significa que o arquivo foi alterado – no nosso caso, isso pode indicar que alguma dependência foi adicionada/removida/atualizada.

Assim, o cache fica inútil, e o output cache-hit vai induzir a execução de pip install. Voltamos então à à estaca zero: as dependências são instaladas mais uma vez e o cache é atualizado.
Confere nos logs! (em inglês)

Comentários finais sobre caches

  • A opção de auto-armazenar o cache em executores auto-hospedados só está disponível nos planos Enterprise.
  • A ação actions/cache gerencia o cache de forma centralizada. Isso significa que o cache fica disponível e atualizável para todos os trabalhos no mesmo repo - e até mesmo para outros fluxos de trabalho.
  • Leia mais sobre estratégias de cache aqui (em inglês).

Eita que post grande, socorro.