name: build-api-image on: push: branches: # Branch = ambiente. Um merge aceito aqui constroi e implanta no ambiente # correspondente (mapeamento no step de metadados). - develop # -> teste - 'release-*' # -> homologacao - feat_CI_CD_TEST # -> teste (branch de validacao da propria esteira) tags: - '2.*' jobs: build: runs-on: ubuntu-latest env: REGISTRY: ${{ vars.REGISTRY_HOST || '192.168.0.41:3001' }} IMAGE_NAME: ${{ vars.API_IMAGE_NAME || 'rkm/conectasus-api' }} BASE_IMAGE: ${{ vars.API_BASE_IMAGE || 'tirkm/v2-application:6' }} # O contexto e a raiz do application: o build precisa enxergar api/ e # frontend/ ao mesmo tempo, entao Dockerfile e docker/ vivem na raiz. CONTEXT_DIR: ${{ vars.CONTEXT_DIR || '.' }} DOCKERFILE: ${{ vars.DOCKERFILE || 'Dockerfile' }} # Subdiretorio da API, usado nas checagens que leem arquivos do repo # (composer.json, migrations) — nao confundir com o contexto do build. API_DIR: ${{ vars.API_DIR || 'api' }} PUSH_LATEST: ${{ vars.PUSH_LATEST || 'true' }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Definir metadados do build id: meta run: | set -euo pipefail if [ "${GITHUB_REF}" != "${GITHUB_REF#refs/tags/}" ]; then IS_TAG=true else IS_TAG=false fi VERSION=$(echo "${GITHUB_REF_NAME}" | tr '/' '-') IMAGE="${REGISTRY}/${IMAGE_NAME}" # Branch -> ambiente. A tag de release constroi a imagem, mas nao # implanta nem aplica migrations: ambos exigem acao manual. case "${GITHUB_REF_NAME}" in develop|feat_CI_CD_TEST) AMBIENTE=teste ;; release-*) AMBIENTE=homolog ;; *) AMBIENTE="" ;; esac [ "${IS_TAG}" = "true" ] && AMBIENTE="" # Uma imagem serve todos os ambientes: a config do frontend e resolvida # em runtime (config.js montado no deploy), nao gravada no bundle. # Toda imagem tambem recebe uma tag pelo commit, para rastreabilidade # e retencao. Tags de release sempre constroem uma imagem nova a # partir do commit marcado: a tag e o marco formal do artefato. SHA_TAG="sha-$(echo "${GITHUB_SHA}" | cut -c1-8)" echo "ambiente=${AMBIENTE}" >> "$GITHUB_OUTPUT" echo "sha_tag=${SHA_TAG}" >> "$GITHUB_OUTPUT" echo "tag_sha=${IMAGE}:${SHA_TAG}" >> "$GITHUB_OUTPUT" echo "is_tag=${IS_TAG}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "tag_version=${IMAGE}:${VERSION}" >> "$GITHUB_OUTPUT" echo "tag_latest=${IMAGE}:latest" >> "$GITHUB_OUTPUT" echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" echo "Evento : $([ "${IS_TAG}" = "true" ] && echo tag || echo branch)" echo "Versao : ${VERSION}" echo "Imagem : ${IMAGE}:${VERSION}" echo "Base : ${BASE_IMAGE}" - name: Conferir versao do composer.json run: | set -euo pipefail if [ "${{ steps.meta.outputs.is_tag }}" != "true" ]; then echo "Build de branch; comparacao com composer.json nao se aplica." exit 0 fi DECLARED=$(grep -m1 '"version"' "${API_DIR}/composer.json" \ | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') TAG="${{ steps.meta.outputs.version }}" if [ "${DECLARED}" != "${TAG}" ]; then echo "::warning::composer.json declara versao '${DECLARED}' mas a tag e '${TAG}'. Build segue com a tag." else echo "composer.json e tag batem: ${TAG}" fi - name: Avaliar reversibilidade das migrations id: rollback run: | set -euo pipefail PREV=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true) if [ -z "${PREV}" ]; then echo "Nenhuma tag anterior encontrada; analise de rollback ignorada." echo "safe=unknown" >> "$GITHUB_OUTPUT" exit 0 fi echo "Comparando ${PREV} -> ${GITHUB_REF_NAME}" NOVAS=$(git diff --name-only --diff-filter=A "${PREV}" "${GITHUB_REF_NAME}" \ -- "${API_DIR}/database/migrations" || true) if [ -z "${NOVAS}" ]; then echo "Nenhuma migration nova neste release." echo "safe=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "Migrations novas:" echo "${NOVAS}" | sed 's/^/ /' DESTRUTIVAS="" while IFS= read -r f; do [ -f "${f}" ] || continue UP=$(sed '/function[[:space:]]*down[[:space:]]*(/,$d' "${f}") if echo "${UP}" | grep -qE 'dropColumn|renameColumn|dropIfExists|DROP TABLE|DROP COLUMN'; then DESTRUTIVAS="${DESTRUTIVAS}${f}"$'\n' fi done <<< "${NOVAS}" { echo "### Reversibilidade" echo "" echo "Comparado com \`${PREV}\`." } >> "$GITHUB_STEP_SUMMARY" if [ -z "${DESTRUTIVAS}" ]; then echo "Todas as migrations novas sao aditivas." echo "safe=true" >> "$GITHUB_OUTPUT" { echo "" echo "Migrations novas sao **aditivas**. Rollback da imagem para \`${PREV}\` e seguro mesmo com o schema ja migrado." } >> "$GITHUB_STEP_SUMMARY" exit 0 fi echo "safe=false" >> "$GITHUB_OUTPUT" echo "${DESTRUTIVAS}" | while IFS= read -r f; do [ -n "${f}" ] && echo "::warning file=${f}::Migration destrutiva no up(): rollback para ${PREV} nao sera seguro apos aplicar." done { echo "" echo "**Atencao: este release contem migrations destrutivas.**" echo "" echo "Depois de aplicar a migration, voltar a imagem para \`${PREV}\` NAO restaura o funcionamento:" echo "o codigo antigo vai referenciar colunas/tabelas que deixaram de existir. Faca backup do banco" echo "antes de migrar, ou planeje a correcao como hotfix para frente." echo "" echo "| Migration |" echo "| --- |" echo "${DESTRUTIVAS}" | while IFS= read -r f; do [ -n "${f}" ] && echo "| \`${f}\` |" done } >> "$GITHUB_STEP_SUMMARY" - name: Login no Docker Hub (imagem base) env: DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | set -euo pipefail if [ -z "${DOCKERHUB_TOKEN:-}" ]; then echo "DOCKERHUB_TOKEN nao definido; assumindo imagem base publica ou ja em cache." exit 0 fi echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USER}" --password-stdin - name: Login no registry do Gitea env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REGISTRY_USER: ${{ vars.REGISTRY_USER }} ACTOR: ${{ github.actor }} run: | set -euo pipefail USER="${REGISTRY_USER:-$ACTOR}" TOKEN="${REGISTRY_TOKEN:-}" if [ -z "${TOKEN}" ]; then echo "REGISTRY_TOKEN ausente; usando o GITEA_TOKEN do job." TOKEN="${GITEA_TOKEN}" USER="${ACTOR}" fi echo "${TOKEN}" | docker login "${REGISTRY}" -u "${USER}" --password-stdin - name: Build da imagem run: | set -euo pipefail docker build \ --file "${DOCKERFILE}" \ --build-arg BASE_IMAGE="${BASE_IMAGE}" \ --build-arg APP_VERSION="${{ steps.meta.outputs.version }}" \ --build-arg VCS_REF="${GITHUB_SHA}" \ --build-arg BUILD_DATE="${{ steps.meta.outputs.build_date }}" \ --build-arg SOURCE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --tag "${{ steps.meta.outputs.tag_version }}" \ --tag "${{ steps.meta.outputs.tag_sha }}" \ "${CONTEXT_DIR}" - name: Smoke test da imagem run: | set -euo pipefail IMG="${{ steps.meta.outputs.tag_version }}" echo "--> aplicacao deve bootar com config presente" docker run --rm --entrypoint bash "${IMG}" -c ' set -e cd /var/www/api cp .env.example .env cp connections.json.example connections.json php artisan --version test -f bootstrap/cache/routes-v7.php || test -f bootstrap/cache/routes.php test -f bootstrap/cache/packages.php ' echo "--> imagem nao pode carregar credenciais" if docker run --rm --entrypoint bash "${IMG}" -c 'test -f /var/www/api/.env || test -f /var/www/api/connections.json'; then echo "::error::.env ou connections.json foram parar dentro da imagem" exit 1 fi echo "--> entrypoint deve recusar subir sem config" if docker run --rm "${IMG}" true 2>/dev/null; then echo "::error::entrypoint subiu sem .env/connections.json" exit 1 fi echo "--> frontend deve estar publicado no dist" docker run --rm --entrypoint bash "${IMG}" -c ' set -e test -f /var/www/frontend/dist/index.html test -d /var/www/frontend/dist/js test -d /var/www/frontend/dist/css ' echo "--> imagem NAO pode ter URL de ambiente gravada no bundle" # O ponto da config em runtime e a imagem ser neutra. Se alguem voltar # a ler process.env.VUE_APP_* num componente, o webpack grava o valor # no bundle e a imagem silenciosamente vira especifica de um ambiente — # o que quebra a promessa de "o que foi homologado e o que vai a prod". # O index.html precisa carregar o config.js antes do bundle. docker run --rm --entrypoint bash "${IMG}" -c ' set -e test -f /var/www/frontend/dist/config.js grep -q "__APP_CONFIG__" /var/www/frontend/dist/config.js grep -q "config.js" /var/www/frontend/dist/index.html ' echo "--> apache deve servir api na :80 e frontend na :8080" docker run --rm --entrypoint bash "${IMG}" -c ' set -e apache2ctl configtest 2>&1 | grep -q "Syntax OK" apache2ctl -S 2>/dev/null | grep -q "^\*:80 " apache2ctl -S 2>/dev/null | grep -q "^\*:8080 " ' # As checagens acima usam --entrypoint bash, que nunca executa o CMD. # As de baixo sobem o container de verdade: sao as unicas que pegam # defeito de supervisord/apache que so aparece no boot real. # # O job roda com o docker.sock do host, entao bind mount de arquivo # criado aqui dentro nao chegaria ao daemon (o caminho nao existe no # host). Por isso a config entra via 'docker cp' num container criado # e ainda nao iniciado. TMP=$(mktemp -d) docker run --rm --entrypoint bash "${IMG}" -c 'cat /var/www/api/.env.example' > "${TMP}/.env" docker run --rm --entrypoint bash "${IMG}" -c 'cat /var/www/api/connections.json.example' > "${TMP}/connections.json" sed -i "s|^APP_KEY=.*|APP_KEY=base64:$(head -c 32 /dev/urandom | base64)|" "${TMP}/.env" printf 'window.__APP_CONFIG__ = { ROOT_API: "http://smoke-test", DNS: {}, ENV: "" }\n' > "${TMP}/config.js" chmod 644 "${TMP}/.env" "${TMP}/connections.json" "${TMP}/config.js" echo "--> entrypoint deve recusar subir sem a config de runtime do frontend" # Sem o mount, o app subiria apontando para lugar nenhum em vez de # falhar — o silencio e que e perigoso aqui. CID_SEM=$(docker create "${IMG}") docker cp "${TMP}/.env" "${CID_SEM}:/var/www/api/.env" docker cp "${TMP}/connections.json" "${CID_SEM}:/var/www/api/connections.json" docker start "${CID_SEM}" >/dev/null 2>&1 || true sleep 3 if [ "$(docker inspect -f '{{.State.Running}}' "${CID_SEM}")" = "true" ]; then echo "::error::container subiu sem /var/www/frontend/config.js montado" docker rm -f "${CID_SEM}" >/dev/null 2>&1 || true exit 1 fi docker rm -f "${CID_SEM}" >/dev/null 2>&1 || true echo "--> container deve subir com config e permanecer de pe" CID=$(docker create "${IMG}") cleanup() { docker rm -f "${CID}" >/dev/null 2>&1 || true; rm -rf "${TMP}"; } trap cleanup EXIT docker cp "${TMP}/.env" "${CID}:/var/www/api/.env" docker cp "${TMP}/connections.json" "${CID}:/var/www/api/connections.json" docker cp "${TMP}/config.js" "${CID}:/var/www/frontend/config.js" docker start "${CID}" >/dev/null for _ in $(seq 1 15); do docker exec "${CID}" curl -s -o /dev/null http://localhost:8080/ 2>/dev/null && break sleep 2 done if [ "$(docker inspect -f '{{.State.Running}}' "${CID}")" != "true" ]; then echo "::error::container nao permaneceu de pe (exit=$(docker inspect -f '{{.State.ExitCode}}' "${CID}"))" docker logs "${CID}" 2>&1 | tail -20 exit 1 fi CODE_API=$(docker exec "${CID}" curl -s -o /dev/null -w '%{http_code}' http://localhost:80/) CODE_SPA=$(docker exec "${CID}" curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/) CODE_ROTA=$(docker exec "${CID}" curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/rota/interna) echo " api :80 -> ${CODE_API} | spa :8080 -> ${CODE_SPA} | spa /rota -> ${CODE_ROTA}" # A API nao tem rota "/", entao 404 e resposta valida; o que nao pode e # 000 (sem resposta). Ja o SPA precisa de 200 na raiz e tambem na rota # interna — este segundo cobre o FallbackResource do mode: 'history'. [ "${CODE_API}" != "000" ] || { echo "::error::apache nao respondeu na :80"; exit 1; } [ "${CODE_SPA}" = "200" ] || { echo "::error::frontend nao respondeu 200 na :8080"; exit 1; } [ "${CODE_ROTA}" = "200" ] || { echo "::error::FallbackResource do SPA nao esta ativo"; exit 1; } echo "--> config.js servido deve ser o montado, e sem cache" # Fecha o ciclo: nao basta o entrypoint copiar, o Apache precisa servir # o arquivo instalado. E o no-store evita que o browser continue com a # URL do ambiente anterior depois de uma troca. SERVIDO=$(docker exec "${CID}" curl -s http://localhost:8080/config.js) echo "${SERVIDO}" | grep -q 'smoke-test' \ || { echo "::error::config.js servido nao e o montado: ${SERVIDO}"; exit 1; } docker exec "${CID}" curl -sI http://localhost:8080/config.js | grep -qi 'cache-control: *no-store' \ || { echo "::error::config.js servido sem Cache-Control no-store (mod_headers habilitado?)"; exit 1; } echo "Smoke test OK" - name: Push da imagem run: | set -euo pipefail docker push "${{ steps.meta.outputs.tag_version }}" docker push "${{ steps.meta.outputs.tag_sha }}" if [ "${{ steps.meta.outputs.is_tag }}" != "true" ]; then echo "Build de branch: tag 'latest' preservada." exit 0 fi if [ "${PUSH_LATEST}" = "true" ]; then docker tag "${{ steps.meta.outputs.tag_version }}" "${{ steps.meta.outputs.tag_latest }}" docker push "${{ steps.meta.outputs.tag_latest }}" else echo "PUSH_LATEST=${PUSH_LATEST}: tag 'latest' nao publicada." fi - name: Deploy no ambiente da branch # Branch = ambiente: develop -> teste e release-* -> homolog. # Tags e master nao fazem deploy por este workflow. # # Fica aqui, e nao num workflow proprio disparado por push, porque dois # workflows no mesmo evento sao enfileirados em ordem arbitraria — o # deploy poderia rodar antes de a imagem existir. Aqui a ordem e # garantida e a imagem acabou de ser publicada. # # Precede a Limpeza de proposito: ela faz docker logout, e o deploy # ainda precisa do login para o pull. if: ${{ steps.meta.outputs.ambiente != '' && vars.AUTO_DEPLOY != 'false' }} env: AMBIENTE: ${{ steps.meta.outputs.ambiente }} SSH_HOST: ${{ vars.TEST_SSH_HOST || '192.168.0.44' }} SSH_PORT: ${{ vars.TEST_SSH_PORT || '22' }} SSH_USER: ${{ vars.SSH_USER_TEST || secrets.SSH_USER_TEST }} SSH_PRIVATE_KEY: ${{ secrets.SSH_KEY_TEST }} SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS_TEST }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_USER: ${{ vars.REGISTRY_USER }} ACTOR: ${{ github.actor }} run: | set -euo pipefail # O Gitea publica a tag em minusculas; a branch pode ter maiusculas. TAG=$(echo "${{ steps.meta.outputs.version }}" | tr '[:upper:]' '[:lower:]') if [ "${AMBIENTE}" = "teste" ]; then echo "Deploy de teste usa SSH para ${SSH_HOST}:${SSH_PORT}." chmod +x docker/deploy/deploy-over-ssh.sh ./docker/deploy/deploy-over-ssh.sh "${AMBIENTE}" "${TAG}" else chmod +x docker/deploy/deploy.sh ./docker/deploy/deploy.sh "${AMBIENTE}" "${TAG}" fi - name: Migrations automaticas do ambiente de teste # Migrations nunca acompanham homolog/prod automaticamente. Em teste, # a lista continua explicita para nao atingir conexoes tecnicas do # connections.json. As duas variables sao opt-in deliberado. if: ${{ steps.meta.outputs.ambiente == 'teste' && vars.AUTO_DEPLOY != 'false' && vars.AUTO_MIGRATE_TEST == 'true' }} env: TAG_VERSION: ${{ steps.meta.outputs.version }} MUNICIPIOS: ${{ vars.MIGRATION_CONNECTIONS_TEST }} run: | set -euo pipefail if [ -z "${MUNICIPIOS:-}" ]; then echo "::error::AUTO_MIGRATE_TEST=true exige a variable MIGRATION_CONNECTIONS_TEST com a lista explicita de municipios." exit 1 fi # O Gitea normaliza a tag de branch para minusculas no registry. TAG=$(echo "${TAG_VERSION}" | tr '[:upper:]' '[:lower:]') chmod +x docker/deploy/migrate.sh ./docker/deploy/migrate.sh teste "${TAG}" "${MUNICIPIOS}" - name: Resumo if: always() run: | { echo "### Imagem publicada" echo "" echo "| Campo | Valor |" echo "| --- | --- |" echo "| Versao | \`${{ steps.meta.outputs.version }}\` |" echo "| Imagem | \`${{ steps.meta.outputs.tag_version }}\` |" echo "| Base | \`${BASE_IMAGE}\` |" echo "| Commit | \`${GITHUB_SHA}\` |" echo "" echo "**Uma imagem serve todos os ambientes.** A config do frontend e" echo "resolvida em runtime: monte o \`config.js\` do ambiente em" echo "\`/var/www/frontend/config.js\` (modelo em \`docker/config.js.example\`)." echo "Tags de release constroem a imagem e a publicam no registry." echo "Deploy e migrations de producao continuam sendo passos manuais." echo "" echo "Migrations municipais so executam automaticamente em teste quando" echo "\`AUTO_MIGRATE_TEST=true\` e \`MIGRATION_CONNECTIONS_TEST\` estao definidos." } >> "$GITHUB_STEP_SUMMARY" - name: Limpeza if: always() run: | docker rmi "${{ steps.meta.outputs.tag_version }}" \ "${{ steps.meta.outputs.tag_latest }}" \ "${{ steps.meta.outputs.tag_sha }}" 2>/dev/null || true docker logout "${REGISTRY}" 2>/dev/null || true