Tutoriais

Pipelines com GitHub Actions e Gitea Actions

Tutorial completo de CI/CD com GitHub Actions e Gitea Actions — workflows para Spring Boot, Vue, Astro e Fastify, runners self-hosted, reusable workflows, releases e deploy.

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 main segue até produção sem intervenção. Uma regra comum é Delivery automática + Deployment com gate manual em produção.

GitHub Actions vs Gitea Actions

AspectoGitHub ActionsGitea Actions
ModeloSaaS (GitHub.com)Self-hosted, embutido no Gitea (>=1.19)
RunnerHosted (Linux/Win/macOS) + self-hostedApenas self-hosted (act_runner)
Linguagem do runneractions/runner (.NET)act_runner (Go) baseado em nektos/act
Marketplaceactions/checkout@v4 diretoSuporta a maioria das actions via uses: (resolve via GitHub público ou mirror); permite URLs absolutas (uses: https://gitea.exemplo/org/action@v1)
TokenGITHUB_TOKEN (escopos finos)GITEA_TOKEN + alias GITHUB_TOKEN para compat
ExpressionsCompleto (success(), failure(), cancelled()…)Apenas always() é suportado oficialmente; resto é parcial
paths-ignore / branches-ignoreSuportadoSuportado
jobs.<id>.environmentTotalIgnorado
jobs.<id>.timeout-minutesSuportadoIgnorado
jobs.<id>.continue-on-errorSuportadoIgnorado
Reusable workflows (workflow_call)SuportadoSuportado
Composite actionsSuportadoSuportado
Registry de pacotesGitHub Packages / GHCRGitea Packages (gitea.exemplo/owner/-/packages)
Agendamentos extrasApenas cronCron + aliases (@yearly, @monthly, @weekly, @daily, @hourly)
Problem matchers / anotaçõesSuportadosIgnorados

Comparativo com outras ferramentas

FerramentaResumoQuando faz sentido
JenkinsServer Java + Groovy DSL/Jenkinsfile, infinitamente plugávelTimes com pipelines legados ou que dependem de plugins muito específicos
GitLab CIYAML acoplado ao GitLab, runners shell/docker/k8sJá se usa GitLab como SCM
Drone CIYAML + plugins Docker, leve, foco em containersSubstituto histórico do Travis para self-hosted
WoodpeckerFork comunitário do Drone, escolha popular em ambientes self-hosted antes do Gitea ActionsQuem quer algo minimalista e desacoplado
Gitea ActionsCompat. com GitHub, mesmo YAML, integra com GiteaSelf-hosted + estética GitHub
GitHub ActionsPadrão de mercado, marketplace giganteRepositó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 --> SRV

Topologia 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 --> REG

O 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 UTC

Job

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: bash

Matrix

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-latest

env, 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: 7

Services (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 5

Permissions 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/Vault

No 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ãoValor (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 = true

Reiniciar 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.yaml

Trechos 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.yaml

5. 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.txt

DinD vs DooD

EstratégiaComoPrósContras
DooD (Docker-out-of-Docker)Montar /var/run/docker.sock no runnerBuilds rápidos (compartilha cache do daemon), single layerWorkflows podem manipular containers do host; imagem ganhada com docker build fica no host
DinD (Docker-in-Docker)Container docker:dind + DOCKER_HOST=tcp://dind:2375Isolamento completo2x download/storage, exige --privileged, mais lento
Buildx + buildkitd remotosetup-buildx-action apontando para buildkitd standaloneMulti-arch + cache distribuídoMais 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:

  1. Settings -> Actions -> Runners -> New self-hosted runner.
  2. 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
  1. 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 -f

Dockerfile 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=max

Dockerfile 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}"
};
EOF

9. 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 reload

10. 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=max

11. 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 }}
        EOF

Uso:

- uses: ./.github/actions/java-setup
  with:
    nexus-user: ${{ secrets.NEXUS_USER }}
    nexus-token: ${{ secrets.NEXUS_TOKEN }}

12. Secrets e variables

Hierarquia

  1. Organization secrets/variables - visíveis a todos os repos da org (com allowlist).
  2. Repository - escopo do repo.
  3. Environment (apenas GitHub no momento) - escopo de um deploy alvo, com regras de proteção.

Boas práticas

  • Nunca prefixar segredos com GITHUB_ ou GITEA_ (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.sh

No 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: maven

Cache 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-lockfile

Trade-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: no act_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 0

Job 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 -f

Alternativas

PadrãoResumoPrósContras
SSH direto (este tutorial)Job final faz ssh no servidor e executa docker compose pull/upSimples, controle totalAcoplamento: pipeline precisa de SSH key e abertura de porta
Webhook Portainer/KomodoCI faz curl POST num webhook que dispara redeploy do stackSem SSH keyDepende de Portainer; auditoria pior
WatchtowerContainer no servidor verifica registry periodicamente e atualizaZero CD logic no CINão escala bem; janela de atraso
GitOps (Renovate + ArgoCD/Flux)Bot abre PR atualizando tag em infra/compose.yml; ferramenta sincronizaAuditável, rollback fácilStack mais pesada; over-engineering para ambientes pequenos
Pull-based via cronCron no servidor roda docker compose pull && upSem necessidade de inboundLatê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-backend

Cada 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: true

Branch protection

  • GitHub: Settings -> Branches -> Branch protection rules -> require static, sonar, trivy-fs antes 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)

ItemGitHubGiteaObservação
Marketplace diretoSim (verificado, badges)Resolve do GitHub público OU mirror próprio (DEFAULT_ACTIONS_URL = github/self)uses: actions/checkout@v4 funciona em ambos
URLs absolutasNãoSim (uses: https://gitea.exemplo/org/action@v1)Permite actions privadas no Gitea
Actions em GoNãoSimDiferencial Gitea
Cron aliases (@daily, @weekly)NãoSim-
GITHUB_TOKENToken efêmero por job, escopos finosGITEA_TOKEN (com alias GITHUB_TOKEN)Escopos no Gitea são por repositório
Runners hospedadosLinux/Win/macOS pago/gratisApenas self-hostedPlano: self-hosted em servidor próprio para ambos
Larger runnersSim (até 96 vCPU GPU)Self-hosted dimensionado por você-
Reusable workflowsSimSimSintaxe idêntica
Composite actionsSimSimSintaxe idêntica
Environments + reviewersSimLimitado/ignoradoUse job manual no Gitea
paths-ignore, branches-ignoreSimSim-
jobs.<id>.timeout-minutesSimIgnoradoConfigurar timeout no act_runner/config.yaml
jobs.<id>.continue-on-errorSimIgnorado-
Expressions (success(), failure(), cancelled())SimApenas always() oficialTestar antes de depender
Problem matchersSimIgnorados-
Annotations (::error file=...)SimIgnoradas-
Container registryGHCR / Docker HubGitea Packages / Docker registry externoregistry.example.com no exemplo
OIDC para cloudsSim (AWS/GCP/Azure/Vault)LimitadoUse secrets normais no Gitea
Reports/UIRich (matrix view, retry job)Mais simplesFuncional

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_runner sai com dial tcp: lookup gitea: no such host -> falta estar na rede proxy.
  • GitHub self-hosted: journalctl -u actions.runner.org-repo.runner-name mostra 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-essential

Docker build muito lento no Gitea

  • Habilitar buildkit cache no registry (cache-from/cache-to).
  • Aumentar capacity do runner em config.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-stdin

Se 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