GitHub Actions: Fluxo e Persistência de Dados em Workflows
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égia | Dados | Escopo | Persistência | Detalhes | Exemplo |
---|---|---|---|---|---|
env | Valores | Trabalho (interno) | Efêmero | Propaga dados entre etapas no mesmo trabalho | Passar um booleano para determinar se a próxima estapa deve ser executada |
outputs | Valores | Fluxo de trabalho (interno) | Efêmero | Propaga dados entre trabalhos/etapas no mesmo fluxo de trabalho | Passar um ID para o próximo trabalho |
artefatos | Arquivos | Fluxo de trabalho (interno e externo) | Persistente | Propaga 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. |
cache | Arquivos | Fluxo de trabalho (interno e externo) | Persistente | Propaga 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
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.
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.
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 }}
(...)
- Um output individual deve ter no máximo 1 MB.
- Todos os outputs combinados não devem exceder 50MB.
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.
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 dav4
, 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
:
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:
- usar a UI do GitHub
- usar a API do GitHub
- escrever um script personalizado usando a API do Github ou usar uma ação criada pela comunidade.
Como usar cache
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 umakey
. Akey
é 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).
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):
- logs da definição do cache: primeira execução, não há cache, então o cache é definido
- logs do uso do cache: na segunda execução, o cache é encontrado e usado
- logs de atualização do cache: o arquivo requirements.txt foi alterado, então o cache não coincide. As dependências são reinstaladas e o cache é atualizado.
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
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.
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.