Tutorial denso e prático sobre como projetar, configurar e operar pipelines de Integração Contínua e Entrega Contínua usando GitHub Actions (SaaS) e Gitea Actions (self-hosted, compatível com o ecossistema do GitHub). Foco em stacks comuns: Spring Boot 3 + Java 21, Vue 3 + Vite, Astro estático e Fastify + Drizzle. Os exemplos foram escritos para serem copiáveis e funcionam tanto em .github/workflows/ quanto em .gitea/workflows/ com diferenças apontadas onde existem.
1. Visão geral
CI vs CD
- CI (Continuous Integration): todo push/PR dispara build + testes + análise estática. Garante que o branch principal nunca quebra. Artefato é normalmente um JAR, bundle ou imagem Docker.
- CD (Continuous Delivery): o artefato validado é publicado num registry/repositório e fica pronto para deploy manual ou automático.
- CD (Continuous Deployment): o passo seguinte; cada commit aprovado em
mainsegue até produção sem intervenção. Uma regra comum é Delivery automática + Deployment com gate manual em produção.
GitHub Actions vs Gitea Actions
| Aspecto | GitHub Actions | Gitea Actions |
|---|---|---|
| Modelo | SaaS (GitHub.com) | Self-hosted, embutido no Gitea (>=1.19) |
| Runner | Hosted (Linux/Win/macOS) + self-hosted | Apenas self-hosted (act_runner) |
| Linguagem do runner | actions/runner (.NET) | act_runner (Go) baseado em nektos/act |
| Marketplace | actions/checkout@v4 direto | Suporta a maioria das actions via uses: (resolve via GitHub público ou mirror); permite URLs absolutas (uses: https://gitea.exemplo/org/action@v1) |
| Token | GITHUB_TOKEN (escopos finos) | GITEA_TOKEN + alias GITHUB_TOKEN para compat |
| Expressions | Completo (success(), failure(), cancelled()…) | Apenas always() é suportado oficialmente; resto é parcial |
paths-ignore / branches-ignore | Suportado | Suportado |
jobs.<id>.environment | Total | Ignorado |
jobs.<id>.timeout-minutes | Suportado | Ignorado |
jobs.<id>.continue-on-error | Suportado | Ignorado |
Reusable workflows (workflow_call) | Suportado | Suportado |
| Composite actions | Suportado | Suportado |
| Registry de pacotes | GitHub Packages / GHCR | Gitea Packages (gitea.exemplo/owner/-/packages) |
| Agendamentos extras | Apenas cron | Cron + aliases (@yearly, @monthly, @weekly, @daily, @hourly) |
| Problem matchers / anotações | Suportados | Ignorados |
Comparativo com outras ferramentas
| Ferramenta | Resumo | Quando faz sentido |
|---|---|---|
| Jenkins | Server Java + Groovy DSL/Jenkinsfile, infinitamente plugável | Times com pipelines legados ou que dependem de plugins muito específicos |
| GitLab CI | YAML acoplado ao GitLab, runners shell/docker/k8s | Já se usa GitLab como SCM |
| Drone CI | YAML + plugins Docker, leve, foco em containers | Substituto histórico do Travis para self-hosted |
| Woodpecker | Fork comunitário do Drone, escolha popular em ambientes self-hosted antes do Gitea Actions | Quem quer algo minimalista e desacoplado |
| Gitea Actions | Compat. com GitHub, mesmo YAML, integra com Gitea | Self-hosted + estética GitHub |
| GitHub Actions | Padrão de mercado, marketplace gigante | Repositórios públicos ou times no GitHub Cloud |
Uma estratégia comum é manter o mesmo workflow YAML versionado em ambos: o que está no GitHub vai espelhado para o Gitea e cada lado executa no seu runner. Reduz lock-in e dá redundância.
2. Arquitetura
flowchart TB
subgraph Dev[Estacao do Dev]
D[git push / PR]
end
subgraph SCM[SCM Layer]
GH[GitHub.com]
GT[gitea.example.com]
end
subgraph Events[Trigger Events]
E1[push]
E2[pull_request]
E3[workflow_dispatch]
E4[schedule]
E5[tag v*]
end
subgraph Runners[Runner Pool]
GHR[GitHub-hosted ubuntu-latest]
SHR[self-hosted runner]
ACT[act_runner em Docker Compose]
end
subgraph Steps[Job Steps]
S1[checkout]
S2[setup-java / setup-node]
S3[build]
S4[test + services postgres]
S5[docker build push]
end
subgraph Storage[Artefatos / Cache]
AR[Artifacts]
CA[Action Cache]
end
subgraph Deploy[Deploy Target]
REG[registry.example.com]
SRV[Servidor Linux - docker compose]
end
D --> GH
D --> GT
GH --> E1 & E2 & E3 & E4 & E5
GT --> E1 & E2 & E3 & E4 & E5
E1 & E2 & E3 & E4 & E5 --> GHR
E1 & E2 & E3 & E4 & E5 --> SHR
E1 & E2 & E3 & E4 & E5 --> ACT
GHR --> S1 --> S2 --> S3 --> S4 --> S5
SHR --> S1
ACT --> S1
S3 --> CA
S4 --> AR
S5 --> REG
REG --> SRVTopologia dos runners self-hosted
flowchart LR
subgraph SRV[Servidor Linux]
subgraph Compose[docker compose - infra]
G[gitea]
R1[act_runner #1 - labels: ubuntu-latest, debian]
R2[act_runner #2 - labels: java21]
REG[registry]
NG[nginx-proxy]
end
Sock[/var/run/docker.sock]
end
Dev[Dev push] -- ssh --> G
G -- HTTP poll --> R1
G -- HTTP poll --> R2
R1 -- mount --> Sock
R2 -- mount --> Sock
R1 -- docker push --> REG
NG -- TLS --> G
NG -- TLS --> REGO act_runner faz long-polling HTTP contra o Gitea (não recebe webhook). Por isso ele só precisa de saída para https://gitea.example.com e não exige nenhuma porta exposta. Os jobs são executados em containers spawned pelo runner; como o socket /var/run/docker.sock está montado (estratégia DooD - Docker out of Docker), o build de imagem usa o daemon do host.
3. Conceitos comuns aos dois
Workflow
Arquivo YAML em .github/workflows/<nome>.yml (GitHub) ou .gitea/workflows/<nome>.yml (Gitea). O Gitea também aceita .github/workflows/ para facilitar mirrors.
name: ci
on:
push:
branches: [main, develop]
paths-ignore:
- "**.md"
- "docs/**"
pull_request:
workflow_dispatch:
inputs:
deploy_env:
description: "Ambiente alvo"
required: true
default: staging
type: choice
options: [staging, production]
schedule:
- cron: "0 3 * * 1" # toda segunda 03:00 UTCJob
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps: []
test:
needs: build # sequencial
runs-on: ubuntu-latest
steps: []
lint:
runs-on: ubuntu-latest # paralelo a build (sem needs)
steps: []Jobs sem needs rodam em paralelo. needs: [build, lint] espera ambos.
Step
steps:
- name: Checkout
uses: actions/checkout@v4 # action reutilizavel
- name: Compilar
run: ./gradlew build # script shell
env:
GRADLE_OPTS: "-Xmx2g"
working-directory: ./api
shell: bashMatrix
strategy:
fail-fast: false
max-parallel: 4
matrix:
java: [17, 21]
os: [ubuntu-latest]
include:
- java: 21
os: ubuntu-latest
experimental: true
exclude:
- java: 17
os: macos-latestenv, secrets, outputs
env: # global ao workflow
REGISTRY: registry.example.com
jobs:
build:
env: # global ao job
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
steps:
- id: meta
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- run: echo "Tag = ${{ steps.meta.outputs.tag }}"
env:
SECRET_TOKEN: ${{ secrets.NEXUS_TOKEN }}Cache e artifacts
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- uses: actions/upload-artifact@v4
with:
name: jar
path: build/libs/*.jar
retention-days: 7Services (containers sidecar)
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U app -d app_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5Permissions do GITHUB_TOKEN (apenas GitHub)
permissions:
contents: read # default seguro
packages: write # para push em ghcr.io
pull-requests: write # comentar em PR
id-token: write # OIDC para AWS/VaultNo Gitea o equivalente é o GITEA_TOKEN, gerado por job, com escopos limitados ao repositório que disparou o workflow. Para acessar outros repos do mesmo dono, use um Personal Access Token registrado em secrets.
Contextos ${{ github.* }} vs ${{ gitea.* }}
O Gitea expõe ambos os namespaces como alias para máxima compatibilidade:
| Expressão | Valor (Gitea) |
|---|---|
${{ github.ref }} | refs/heads/main |
${{ gitea.ref }} | idem |
${{ github.sha }} | SHA completo |
${{ github.repository }} | owner/repo |
${{ github.actor }} | usuário disparador |
${{ github.server_url }} | https://gitea.example.com |
Use sempre github.* para manter o YAML portável entre os dois.
4. Gitea Actions: setup
Habilitar no app.ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = github
; ZOMBIE_TASK_TIMEOUT = 10m
; ENDLESS_TASK_TIMEOUT = 3h
; ABANDONED_JOB_TIMEOUT = 24h
[packages]
ENABLED = trueReiniciar Gitea após alterar.
Criar token de registro
- Instance-wide:
Site Administration->Actions->Runners->Create new Runner. - Org-wide:
Org Settings->Actions->Runners. - Repo:
Repo Settings->Actions->Runners.
O token é apresentado uma única vez.
Registrar runner com binário
Baixe o release de https://gitea.com/gitea/act_runner/releases (renomeie para act_runner).
./act_runner register \
--no-interactive \
--instance https://gitea.example.com \
--token <TOKEN_DE_REGISTRO> \
--name runner-1 \
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest,ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04,debian:host"Cada par label:imagem controla o ambiente:
ubuntu-latest:docker://catthehacker/ubuntu:act-latest-> imagem que mimica o GitHub-hosted.debian:host-> executa direto no host (sem isolamento).java21:docker://eclipse-temurin:21-jdk-> imagem pré-aquecida com Java 21.
Gerar config.yaml
./act_runner generate-config > /etc/act_runner/config.yamlTrechos relevantes:
log:
level: info
runner:
file: .runner
capacity: 4 # quantos jobs paralelos
envs:
GOPROXY: https://proxy.golang.org
NODE_OPTIONS: "--max-old-space-size=4096"
env_file: .env
timeout: 3h
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
- "java21:docker://eclipse-temurin:21-jdk"
- "node20:docker://node:20-bullseye"
cache:
enabled: true
dir: ""
host: ""
port: 0
external_server: ""
container:
network: "proxy" # mesma rede do nginx-proxy
privileged: false
options: ""
workdir_parent: ""
valid_volumes: []
docker_host: "-" # auto-detecta /var/run/docker.sock
force_pull: false
host:
workdir_parent: ""Iniciar daemon:
./act_runner daemon --config /etc/act_runner/config.yaml5. act_runner em Docker Compose
Encaixar no /opt/docker/docker-compose.yml:
services:
act_runner:
image: gitea/act_runner:0.2.11
container_name: act_runner
restart: unless-stopped
depends_on:
- gitea
environment:
GITEA_INSTANCE_URL: https://gitea.example.com
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /run/secrets/runner_token
GITEA_RUNNER_NAME: runner-1
GITEA_RUNNER_LABELS: >-
ubuntu-latest:docker://catthehacker/ubuntu:act-latest,
ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04,
java21:docker://eclipse-temurin:21-jdk,
node20:docker://node:20-bullseye
CONFIG_FILE: /config.yaml
volumes:
- ./act_runner/config.yaml:/config.yaml:ro
- act_runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
networks:
- proxy
secrets:
- runner_token
volumes:
act_runner_data:
networks:
proxy:
external: true
secrets:
runner_token:
file: ./secrets/runner_token.txtDinD vs DooD
| Estratégia | Como | Prós | Contras |
|---|---|---|---|
| DooD (Docker-out-of-Docker) | Montar /var/run/docker.sock no runner | Builds rápidos (compartilha cache do daemon), single layer | Workflows podem manipular containers do host; imagem ganhada com docker build fica no host |
| DinD (Docker-in-Docker) | Container docker:dind + DOCKER_HOST=tcp://dind:2375 | Isolamento completo | 2x download/storage, exige --privileged, mais lento |
| Buildx + buildkitd remoto | setup-buildx-action apontando para buildkitd standalone | Multi-arch + cache distribuído | Mais peças móveis |
A escolha padrão neste tutorial é DooD: simples, performático e adequado quando todos os pushes vêm de devs confiáveis. Para ambientes multi-tenant a recomendação é DinD.
Por que proxy?
O runner precisa resolver gitea internamente quando faz git clone (caso contrário sai pela internet e bate no Nginx, com latência adicional). Estar na mesma rede do nginx-proxy resolve isso via DNS embutido do Docker.
6. GitHub Actions: setup
Hosted runners
ubuntu-latest,ubuntu-22.04,windows-latest,macos-latest.- Em repositórios privados o plano gratuito dá 2000 minutos/mês de Linux (Windows = 2x, macOS = 10x).
- Em repositórios públicos os minutos são ilimitados.
- Larger runners (4/8/16 vCPU, GPU) são pagos.
Self-hosted runner
Útil quando:
- Build precisa de dependências internas (Nexus interno, VPN).
- Quer cache local persistente entre runs.
- Quer máquina mais potente que
ubuntu-latest(2 vCPU / 7 GB). - O artefato final é enviado para infra privada.
Passos:
Settings -> Actions -> Runners -> New self-hosted runner.- Copiar comandos:
mkdir actions-runner && cd actions-runner
curl -O -L https://github.com/actions/runner/releases/download/v2.317.0/actions-runner-linux-x64-2.317.0.tar.gz
tar xzf actions-runner-linux-x64-2.317.0.tar.gz
./config.sh --url https://github.com/org/repo --token AAAA --labels "self-hosted,linux,onprem" --unattended
sudo ./svc.sh install
sudo ./svc.sh start- Trocar
runs-on: ubuntu-latest->runs-on: [self-hosted, linux, onprem].
Cuidado: rode self-hosted runners apenas em repositórios privados. Se forem públicos, qualquer PR externo pode executar código arbitrário no seu runner.
7. Workflow exemplo 1 - Spring Boot Java 21 (Gradle)
Arquivo .github/workflows/ci.yml (mesmo arquivo serve em .gitea/workflows/ci.yml).
name: ci-backend
on:
push:
branches: [main, develop]
paths-ignore:
- "**.md"
- "docs/**"
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-backend-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: registry.example.com
IMAGE_NAME: api-backend
JAVA_VERSION: "21"
jobs:
build:
name: build + unit tests
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
sha_short: ${{ steps.vars.outputs.sha_short }}
version: ${{ steps.vars.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Definir variaveis derivadas
id: vars
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "version=$(grep '^version' build.gradle | awk -F'"' '{print $2}')" >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
cache: gradle
- name: Build (skip tests aqui)
run: ./gradlew build -x test --no-daemon
- name: Unit tests
run: ./gradlew test --no-daemon
- name: Upload jar
uses: actions/upload-artifact@v4
with:
name: jar-${{ steps.vars.outputs.sha_short }}
path: build/libs/*.jar
retention-days: 7
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: build/reports/tests/test
retention-days: 3
integration:
name: integration tests
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U app -d app_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/app_test
SPRING_DATASOURCE_USERNAME: app
SPRING_DATASOURCE_PASSWORD: app
SPRING_PROFILES_ACTIVE: ci
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
cache: gradle
- name: Aguardar postgres
run: |
for i in $(seq 1 30); do
pg_isready -h localhost -U app && break
sleep 2
done
- name: Rodar integration tests
run: ./gradlew integrationTest --no-daemon
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: integration-reports
path: build/reports/tests/integrationTest
image:
name: build + push image
needs: [build, integration]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: jar-${{ needs.build.outputs.sha_short }}
path: build/libs
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login no registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ needs.build.outputs.version }}
type=sha,prefix=sha-,format=short
- name: Build + push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
- name: Trivy scan
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.build.outputs.sha_short }}
format: table
exit-code: "1"
severity: HIGH,CRITICAL
ignore-unfixed: true
deploy:
name: deploy servidor
needs: image
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://api.example.com
steps:
- name: SSH deploy
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: 22
script: |
set -euo pipefail
cd /opt/docker/api-backend
docker compose pull backend
docker compose up -d backend
docker image prune -fDockerfile do backend
FROM eclipse-temurin:21-jre-alpine AS runtime
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY build/libs/*.jar app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]8. Workflow exemplo 2 - Vue 3 + Vite
name: ci-frontend
on:
push:
branches: [main]
paths:
- "src/**"
- "public/**"
- "package.json"
- "pnpm-lock.yaml"
- "Dockerfile"
- ".github/workflows/ci-frontend.yml"
pull_request:
env:
REGISTRY: registry.example.com
IMAGE_NAME: web-spa
NODE_VERSION: "20"
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run type-check
- run: pnpm run test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage
build:
needs: quality
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
sha_short: ${{ steps.sha.outputs.value }}
steps:
- uses: actions/checkout@v4
- id: sha
run: echo "value=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
env:
VITE_API_BASE_URL: __RUNTIME__
VITE_KEYCLOAK_URL: __RUNTIME__
VITE_KEYCLOAK_REALM: __RUNTIME__
VITE_KEYCLOAK_CLIENT_ID: __RUNTIME__
- uses: actions/upload-artifact@v4
with:
name: dist-${{ steps.sha.outputs.value }}
path: dist
retention-days: 3
image:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist-${{ needs.build.outputs.sha_short }}
path: dist
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.build.outputs.sha_short }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=maxDockerfile multistage (nginx final + entrypoint runtime config)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /docker-entrypoint.d/40-app-config.sh
RUN chmod +x /docker-entrypoint.d/40-app-config.sh
EXPOSE 80#!/bin/sh
# entrypoint.sh - injeta window.APP_CONFIG em tempo de boot
cat > /usr/share/nginx/html/config.js <<EOF
window.APP_CONFIG = {
API_BASE_URL: "${VITE_API_BASE_URL}",
KEYCLOAK_URL: "${VITE_KEYCLOAK_URL}",
KEYCLOAK_REALM: "${VITE_KEYCLOAK_REALM}",
KEYCLOAK_CLIENT_ID: "${VITE_KEYCLOAK_CLIENT_ID}"
};
EOF9. Workflow exemplo 3 - Astro estático (deploy via rsync SSH)
Site estático (static-site): build no runner, sincroniza com /var/www/static-site/ no servidor remoto.
name: deploy-site
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-site
cancel-in-progress: false # nao cancela deploy em andamento
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run check # astro check
- uses: actions/upload-artifact@v4
with:
name: site
path: dist
retention-days: 1
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: production
url: https://www.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: site
path: dist
- name: Deploy via rsync
uses: easingthemes/ssh-deploy@v5.1.0
with:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REMOTE_HOST: ${{ secrets.DEPLOY_HOST }}
REMOTE_USER: ${{ secrets.DEPLOY_USER }}
REMOTE_PORT: "22"
SOURCE: "dist/"
TARGET: "/opt/docker/static-site/public/"
ARGS: "-avz --delete --chmod=D755,F644"
EXCLUDE: "/.DS_Store, /node_modules/"
- name: Purge cache nginx
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker exec nginx-proxy nginx -s reload10. Workflow exemplo 4 - Fastify TS + Drizzle
name: ci-api
on:
push:
branches: [main, develop]
paths:
- "api/**"
- ".github/workflows/ci-api.yml"
pull_request:
paths:
- "api/**"
defaults:
run:
working-directory: api
env:
REGISTRY: registry.example.com
IMAGE_NAME: fastify-api
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: gs
POSTGRES_PASSWORD: gs
POSTGRES_DB: gs_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U gs -d gs_test"
--health-interval 5s
--health-retries 10
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-retries 10
env:
DATABASE_URL: postgres://gs:gs@localhost:5432/gs_test
REDIS_URL: redis://localhost:6379
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: api/package-lock.json
- run: npm ci
- run: npm run db:migrate
- run: npm run lint
- run: npm run test:unit
- run: npm run test:integration
image:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v6
with:
context: ./api
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max11. Reusable workflows e composite actions
_reusable/build-and-push.yml
name: reusable-build-push
on:
workflow_call:
inputs:
image_name:
required: true
type: string
context:
required: false
type: string
default: "."
dockerfile:
required: false
type: string
default: Dockerfile
platforms:
required: false
type: string
default: "linux/amd64"
secrets:
REGISTRY_USER: { required: true }
REGISTRY_PASSWORD: { required: true }
outputs:
image_tag:
description: "tag publicada"
value: ${{ jobs.build.outputs.image_tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: registry.example.com/${{ inputs.image_name }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
type=ref,event=tag
- uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}Uso:
jobs:
publish:
uses: ./.github/workflows/_reusable/build-and-push.yml
with:
image_name: api-backend
platforms: linux/amd64,linux/arm64
secrets:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}Para herdar todos: secrets: inherit.
Composite action: .github/actions/java-setup/action.yml
name: java-setup
description: Setup Java 21 + Gradle cache + login no Nexus
inputs:
java-version:
description: Java major
required: false
default: "21"
nexus-user:
required: true
nexus-token:
required: true
runs:
using: composite
steps:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ inputs.java-version }}
cache: gradle
- shell: bash
run: |
mkdir -p ~/.gradle
cat > ~/.gradle/gradle.properties <<EOF
nexusUsername=${{ inputs.nexus-user }}
nexusPassword=${{ inputs.nexus-token }}
EOFUso:
- uses: ./.github/actions/java-setup
with:
nexus-user: ${{ secrets.NEXUS_USER }}
nexus-token: ${{ secrets.NEXUS_TOKEN }}12. Secrets e variables
Hierarquia
- Organization secrets/variables - visíveis a todos os repos da org (com allowlist).
- Repository - escopo do repo.
- Environment (apenas GitHub no momento) - escopo de um deploy alvo, com regras de proteção.
Boas práticas
- Nunca prefixar segredos com
GITHUB_ouGITEA_(reservados). - Para mascarar dinamicamente:
echo "::add-mask::$VALOR". - Use Environments para gates em produção:
jobs:
deploy_prod:
environment:
name: production
url: https://app.example.com
runs-on: ubuntu-latest
steps:
- run: ./deploy.shNo GitHub: Settings -> Environments -> production:
- Required reviewers (até 6).
- Wait timer (ex. 5 minutos antes do job rodar).
- Deployment branches (apenas
main). - Secrets exclusivos (
DEPLOY_SSH_KEY_PROD).
No Gitea os environments ainda são limitados; equivalente prático é exigir aprovação manual via workflow_dispatch ou job extra needs: com if: github.event_name == 'workflow_dispatch'.
Mascarar manualmente
- name: Decodificar e mascarar
run: |
SECRET=$(echo "${{ secrets.B64 }}" | base64 -d)
echo "::add-mask::$SECRET"
echo "SECRET_VALUE=$SECRET" >> "$GITHUB_ENV"13. Cache
Por linguagem
# Java + Gradle
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle # equivale a cachear ~/.gradle/caches e wrapper
# Node + pnpm
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm # le pnpm-lock.yaml
# Node + npm
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
# Maven
- uses: actions/setup-java@v4
with:
java-version: 21
cache: mavenCache genérico
- uses: actions/cache@v4
id: cache-libs
with:
path: |
node_modules
.next/cache
key: ${{ runner.os }}-libs-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-libs-
- if: steps.cache-libs.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfileTrade-offs
- Chave muito dinâmica (
${{ github.sha }}) -> hit rate 0%. - Chave estática -> never invalida (perigoso).
- Tamanho máximo do cache no GitHub: 10 GB por repo (LRU). No Gitea o limite é configurável via
cache:noact_runner. - Cache compartilhado entre branches: PR usa caches do branch base; após merge invalida.
14. Matrix builds
name: matrix-backend
on:
pull_request:
jobs:
test:
name: ${{ matrix.os }} / java ${{ matrix.java }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
max-parallel: 4
matrix:
java: [17, 21]
os: [ubuntu-latest]
include:
- java: 21
os: ubuntu-latest
experimental: true
extra-args: "--scan"
exclude:
- java: 17
os: macos-latest
continue-on-error: ${{ matrix.experimental || false }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ matrix.java }}
cache: gradle
- run: ./gradlew test ${{ matrix.extra-args }}Cenários típicos:
- Testar projeto em Java 17 e 21 enquanto migra.
- Testar Node 18/20/22.
- Cross-platform Linux/macOS/Windows para libs.
15. Deploy: push pro registry + pull no servidor
Padrão “push + pull”
sequenceDiagram
participant CI as CI Runner
participant REG as registry.example.com
participant SRV as Servidor Linux
CI->>REG: docker push tag sha-abc + latest
CI->>SRV: ssh "cd /opt/docker/x && docker compose pull && docker compose up -d"
SRV->>REG: docker pull (resolve tag latest)
SRV-->>CI: exit 0Job final (snippet do exemplo 1, expandido):
deploy:
needs: image
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://api.example.com
steps:
- name: SSH deploy
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: 22
envs: IMAGE_TAG
script_stop: true
script: |
set -euo pipefail
export IMAGE_TAG="sha-${GITHUB_SHA::7}"
cd /opt/docker/api-backend
docker compose pull backend
docker compose up -d --remove-orphans backend
docker compose ps backend
docker image prune -fAlternativas
| Padrão | Resumo | Prós | Contras |
|---|---|---|---|
| SSH direto (este tutorial) | Job final faz ssh no servidor e executa docker compose pull/up | Simples, controle total | Acoplamento: pipeline precisa de SSH key e abertura de porta |
| Webhook Portainer/Komodo | CI faz curl POST num webhook que dispara redeploy do stack | Sem SSH key | Depende de Portainer; auditoria pior |
| Watchtower | Container no servidor verifica registry periodicamente e atualiza | Zero CD logic no CI | Não escala bem; janela de atraso |
| GitOps (Renovate + ArgoCD/Flux) | Bot abre PR atualizando tag em infra/compose.yml; ferramenta sincroniza | Auditável, rollback fácil | Stack mais pesada; over-engineering para ambientes pequenos |
| Pull-based via cron | Cron no servidor roda docker compose pull && up | Sem necessidade de inbound | Latência |
Para ambientes simples a recomendação é SSH direto + chave dedicada com command="docker compose ..." no ~/.ssh/authorized_keys para limitar o blast radius.
16. Releases automáticas
Trigger por tag semver
name: release
on:
push:
tags: ["v*.*.*"]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle
- run: ./gradlew build
- name: Gerar changelog
id: changelog
run: |
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV" ]; then
git log --pretty=format:"- %s (%h)" "$PREV"..HEAD > CHANGELOG_RELEASE.md
else
git log --pretty=format:"- %s (%h)" > CHANGELOG_RELEASE.md
fi
- name: Criar release
uses: softprops/action-gh-release@v2
with:
files: |
build/libs/*.jar
body_path: CHANGELOG_RELEASE.md
generate_release_notes: true
draft: false
prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') }}Release Please (Conventional Commits -> release automático)
name: release-please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
release-type: java # ou node, simple, etc.
package-name: api-backendCada commit feat: vira minor; fix: vira patch; feat!: ou BREAKING CHANGE: vira major. A action mantém um PR aberto com a próxima versão; ao fazer merge, ela cria a tag e o release.
No Gitea use release-cli ou softprops/action-gh-release apontando para GITEA_TOKEN (a action é compatível mas o endpoint é ${{ github.api_url }} que o Gitea expõe).
17. Lint e quality gates
Workflow PR-only
name: pr-quality
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
security-events: write
jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx eslint . --max-warnings 0
- run: npx prettier --check .
semgrep:
runs-on: ubuntu-latest
container: returntocorp/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep ci --config auto
codeql:
if: ${{ github.repository_owner == 'minha-org' }} # so no GitHub
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with: { languages: javascript-typescript }
- uses: github/codeql-action/analyze@v3
sonar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle
- run: ./gradlew sonar -Dsonar.token=${{ secrets.SONAR_TOKEN }}
trivy-fs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: fs
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: trueBranch protection
- GitHub:
Settings -> Branches -> Branch protection rules-> requirestatic,sonar,trivy-fsantes do merge; exigir review. - Gitea:
Settings -> Branches -> Protected Branches-> “Enable Status Check” e selecionar os jobs.
18. Diferenças GitHub Actions vs Gitea Actions (referência rápida)
| Item | GitHub | Gitea | Observação |
|---|---|---|---|
| Marketplace direto | Sim (verificado, badges) | Resolve do GitHub público OU mirror próprio (DEFAULT_ACTIONS_URL = github/self) | uses: actions/checkout@v4 funciona em ambos |
| URLs absolutas | Não | Sim (uses: https://gitea.exemplo/org/action@v1) | Permite actions privadas no Gitea |
| Actions em Go | Não | Sim | Diferencial Gitea |
Cron aliases (@daily, @weekly) | Não | Sim | - |
GITHUB_TOKEN | Token efêmero por job, escopos finos | GITEA_TOKEN (com alias GITHUB_TOKEN) | Escopos no Gitea são por repositório |
| Runners hospedados | Linux/Win/macOS pago/gratis | Apenas self-hosted | Plano: self-hosted em servidor próprio para ambos |
| Larger runners | Sim (até 96 vCPU GPU) | Self-hosted dimensionado por você | - |
| Reusable workflows | Sim | Sim | Sintaxe idêntica |
| Composite actions | Sim | Sim | Sintaxe idêntica |
| Environments + reviewers | Sim | Limitado/ignorado | Use job manual no Gitea |
paths-ignore, branches-ignore | Sim | Sim | - |
jobs.<id>.timeout-minutes | Sim | Ignorado | Configurar timeout no act_runner/config.yaml |
jobs.<id>.continue-on-error | Sim | Ignorado | - |
Expressions (success(), failure(), cancelled()) | Sim | Apenas always() oficial | Testar antes de depender |
| Problem matchers | Sim | Ignorados | - |
Annotations (::error file=...) | Sim | Ignoradas | - |
| Container registry | GHCR / Docker Hub | Gitea Packages / Docker registry externo | registry.example.com no exemplo |
| OIDC para clouds | Sim (AWS/GCP/Azure/Vault) | Limitado | Use secrets normais no Gitea |
| Reports/UI | Rich (matrix view, retry job) | Mais simples | Funcional |
19. Troubleshooting
Runner ofline
Sintomas: badge “Runner is offline”.
docker logs act_runner-> erro 401? Token de registro expirou; gerar novo e reregistrar (act_runner register --no-interactive ...).act_runnersai comdial tcp: lookup gitea: no such host-> falta estar na redeproxy.- GitHub self-hosted:
journalctl -u actions.runner.org-repo.runner-namemostra falha de TLS -> CA do GitHub Enterprise não confiada.
Workflow não dispara
- Verifique
paths/paths-ignore(uma exclusão de tudo deixa o workflow inerte). - Verifique
branches: PR de branch sem permissão em fork não acessa secrets. - No Gitea, ver
Site Administration -> Monitoring -> Tasks: existe trace por evento.
”Resource not accessible by integration” (GitHub)
permissions:
contents: write # ou packages, pull-requests, id-token...Por padrão o GitHub usa permissões restritas. Ative o que precisa explicitamente.
Cache miss constante
# Errado: chave usa SHA do commit, sempre muda
key: ${{ runner.os }}-${{ github.sha }}
# Certo: hash do lockfile
key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-OutOfMemoryError no Gradle dentro do runner
env:
GRADLE_OPTS: "-Xmx3g -XX:MaxMetaspaceSize=512m"
JAVA_TOOL_OPTIONS: "-Xmx2g"Em runner com 2 GB total, um dos dois (Gradle daemon ou compilação) precisa ter heap reduzido.
”Funciona no GitHub, falha no Gitea”
Causa quase sempre: a imagem catthehacker/ubuntu:act-latest não vem com as mesmas ferramentas que ubuntu-22.04. Soluções:
# Forcar imagem mais completa
runs-on: ubuntu-22.04 # mapeado no act_runner para catthehacker/ubuntu:act-22.04 (full)
# Ou instalar a dependencia que falta
- run: apt-get update && apt-get install -y curl jq build-essentialDocker build muito lento no Gitea
- Habilitar buildkit cache no registry (
cache-from/cache-to). - Aumentar
capacitydo runner emconfig.yaml. - Verificar se o runner está rodando em ARM nativo (M1 Mac) e o build é
linux/amd64(QEMU é 5x mais lento).
Token de registry expira
docker/login-action@v3 falha com 401. Confirmar:
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.example.com -u USUARIO --password-stdinSe o registry é o Nexus, o “password” geralmente é um token gerado em Security -> Realms -> Docker Bearer Token Realm.
Workflow loop infinito (apenas Gitea)
Acontece quando um workflow comita de volta no mesmo branch sem filtros adequados. Use:
on:
push:
branches: [main]
paths-ignore:
- "CHANGELOG.md"E configure o bot para commitar com [skip ci] no header.
20. Referências
- GitHub Actions - docs oficial
- Understanding GitHub Actions
- Reusing workflows
- Using a matrix for your jobs
- Using environments for deployment
- Workflow syntax for GitHub Actions
- Gitea Actions Overview
- Gitea Actions vs GitHub Actions
- Configuring
act_runner nektos/actcatthehacker/docker_imagesdocker/build-push-actiondocker/metadata-actionappleboy/ssh-actioneasingthemes/ssh-deploysoftprops/action-gh-releasegoogleapis/release-please-actionaquasecurity/trivy-action- Conventional Commits