Files
apps-registry/.gitea/workflows/build.yaml
devops e3ab3f7fc0
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
fix(ci): correr Semgrep nativo (venv) en vez de docker run anidado
El runner de Gitea ejecuta cada job dentro de un contenedor propio que
habla con el daemon Docker del host (sibling containers) -- un
`docker run -v "\${{ github.workspace }}:/src"` desde ahí intenta
montar una ruta que solo existe DENTRO del contenedor del job, no en
el host real: "mkdir /workspace: read-only file system". Confirmado en
el run real (job 128, task_id 128) tras el fix del anchor.

Se valida localmente contra docker.gitea.com/runner-images:ubuntu-latest
(la misma imagen del runner real) que python3/pip3 están disponibles.
pip3 install directo falla por paquetes de apt sin metadata compatible
(PyJWT, "RECORD file not found") -- se usa un venv aislado en su lugar,
validado end-to-end con los dos steps extraídos del YAML real.
2026-08-14 23:50:23 -05:00

541 lines
19 KiB
YAML

name: Build and Push Frontend
on:
# NOTA: sin anchors/aliases de YAML (&x / *x) a propósito -- el parser
# de workflows de Gitea Actions no los resuelve en el bloque "on:" y
# descarta el archivo completo con "unknown on type" (visto en vivo el
# 2026-08-15). Las dos listas de paths quedan duplicadas literalmente.
push:
branches:
- main
paths:
# Código y configuración real del frontend.
# Los cambios exclusivos de GitOps (frontend.yaml, ingress, patches, etc.)
# no vuelven a construir la imagen.
- 'workloads/ecommerce/Dockerfile'
- 'workloads/ecommerce/.dockerignore'
- 'workloads/ecommerce/.npmrc'
- 'workloads/ecommerce/package.json'
- 'workloads/ecommerce/package-lock.json'
- 'workloads/ecommerce/next.config.*'
- 'workloads/ecommerce/tsconfig.json'
- 'workloads/ecommerce/tailwind.config.*'
- 'workloads/ecommerce/postcss.config.*'
- 'workloads/ecommerce/eslint.config.*'
- 'workloads/ecommerce/*.js'
- 'workloads/ecommerce/*.mjs'
- 'workloads/ecommerce/*.ts'
- 'workloads/ecommerce/*.tsx'
- 'workloads/ecommerce/app/**'
- 'workloads/ecommerce/src/**'
- 'workloads/ecommerce/components/**'
- 'workloads/ecommerce/lib/**'
- 'workloads/ecommerce/public/**'
- 'workloads/ecommerce/styles/**'
- '.gitea/workflows/build.yaml'
pull_request:
branches:
- main
paths:
- 'workloads/ecommerce/Dockerfile'
- 'workloads/ecommerce/.dockerignore'
- 'workloads/ecommerce/.npmrc'
- 'workloads/ecommerce/package.json'
- 'workloads/ecommerce/package-lock.json'
- 'workloads/ecommerce/next.config.*'
- 'workloads/ecommerce/tsconfig.json'
- 'workloads/ecommerce/tailwind.config.*'
- 'workloads/ecommerce/postcss.config.*'
- 'workloads/ecommerce/eslint.config.*'
- 'workloads/ecommerce/*.js'
- 'workloads/ecommerce/*.mjs'
- 'workloads/ecommerce/*.ts'
- 'workloads/ecommerce/*.tsx'
- 'workloads/ecommerce/app/**'
- 'workloads/ecommerce/src/**'
- 'workloads/ecommerce/components/**'
- 'workloads/ecommerce/lib/**'
- 'workloads/ecommerce/public/**'
- 'workloads/ecommerce/styles/**'
- '.gitea/workflows/build.yaml'
permissions:
contents: write
packages: write
jobs:
# Corre en push y en pull_request, siempre antes que build. Si encuentra
# un secreto commiteado, el step termina con exit code distinto de 0 y,
# por el "needs" del job build, la imagen nunca se construye ni se sube.
gitleaks:
name: Escaneo de Secretos (Gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout del código
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Instalar Gitleaks
shell: bash
run: |
set -euo pipefail
GITLEAKS_VERSION="8.21.2"
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
chmod +x /tmp/gitleaks
/tmp/gitleaks version
# --no-git: escanea el árbol de archivos del checkout (fetch-depth: 1,
# sin historial), no el log de commits. El scan histórico completo del
# repo se corre aparte, manualmente, no en cada push/PR.
- name: Escanear secretos en el árbol de archivos
shell: bash
run: |
set -euo pipefail
/tmp/gitleaks detect \
--source=workloads/ecommerce \
--no-git \
--redact \
--report-format=json \
--report-path=gitleaks-report.json \
--exit-code=1
- name: Publicar reporte de Gitleaks
if: always()
uses: actions/upload-artifact@v3
with:
name: gitleaks-report
path: gitleaks-report.json
if-no-files-found: ignore
build:
name: Construir y Subir Imagen
needs: gitleaks
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
APP_DIR: workloads/ecommerce
MANIFEST_FILE: workloads/ecommerce/frontend.yaml
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
steps:
- name: Checkout del código
uses: actions/checkout@v3
with:
fetch-depth: 1
persist-credentials: true
# Conservamos el esquema estable v1.0.X basado en el número del pipeline.
- name: Definir Versión Semántica
id: vars
shell: bash
run: |
set -euo pipefail
echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- name: Validar secretos del Registry
shell: bash
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
test -n "${REGISTRY_USER}" || {
echo "ERROR: REGISTRY_USER no está configurado."
exit 1
}
test -n "${REGISTRY_PASSWORD}" || {
echo "ERROR: REGISTRY_PASSWORD no está configurado."
exit 1
}
echo "OK: secretos del Registry disponibles."
# Evita que archivos de ejemplo entren en la compilación de Next.js.
# Se eliminan únicamente del workspace temporal del runner.
- name: Preparar contexto de producción
shell: bash
run: |
set -euo pipefail
test -f "${APP_DIR}/Dockerfile"
test -f "${APP_DIR}/package.json"
test -f "${APP_DIR}/package-lock.json"
test -f "${MANIFEST_FILE}"
rm -rf "${APP_DIR}/integration"
find "${APP_DIR}" -type f \( -name '*.example.ts' -o -name '*.example.tsx' \) -delete
echo "Contexto listo:"
du -sh "${APP_DIR}"
# Valida los tres componentes reales que controlan la navegación.
# No busca CategoryMenu.tsx porque ese archivo era código sin uso.
- name: Validar navegación real del catálogo
shell: bash
run: |
set -euo pipefail
NAVBAR_FILE="${APP_DIR}/components/layout/navbar.tsx"
CATALOG_FILE="${APP_DIR}/components/catalog/catalog-client.tsx"
PAGE_FILE="${APP_DIR}/app/catalog/page.tsx"
for FILE in "${NAVBAR_FILE}" "${CATALOG_FILE}" "${PAGE_FILE}"; do
if [ ! -f "${FILE}" ]; then
echo "ERROR: no existe ${FILE}"
exit 1
fi
done
echo "Validando Navbar real: ${NAVBAR_FILE}"
grep -q 'useSearchParams' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no usa useSearchParams."
exit 1
}
grep -q 'currentCategory' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no calcula currentCategory."
exit 1
}
grep -q 'currentCollection' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no calcula currentCollection."
exit 1
}
grep -q 'collection=Marvel' "${NAVBAR_FILE}" || {
echo "ERROR: falta collection=Marvel en navbar.tsx."
exit 1
}
grep -q 'category=Mochilas' "${NAVBAR_FILE}" || {
echo "ERROR: falta category=Mochilas en navbar.tsx."
exit 1
}
grep -q 'aria-current={active ? "page" : undefined}' "${NAVBAR_FILE}" || {
echo "ERROR: navbar.tsx no expone correctamente el menú activo."
exit 1
}
echo "Validando sincronización del catálogo: ${CATALOG_FILE}"
grep -q 'useEffect' "${CATALOG_FILE}" || {
echo "ERROR: catalog-client.tsx no usa useEffect."
exit 1
}
grep -q 'setCategory(initialCategory' "${CATALOG_FILE}" || {
echo "ERROR: catalog-client.tsx no sincroniza initialCategory."
exit 1
}
grep -q 'setCollection(initialCollection' "${CATALOG_FILE}" || {
echo "ERROR: catalog-client.tsx no sincroniza initialCollection."
exit 1
}
grep -q 'setQuery(initialQuery' "${CATALOG_FILE}" || {
echo "ERROR: catalog-client.tsx no sincroniza initialQuery."
exit 1
}
echo "Validando remontaje por parámetros: ${PAGE_FILE}"
grep -q 'key={`${category}:${collection}:${query}:${section}`}' "${PAGE_FILE}" || {
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
exit 1
}
echo "OK: navegación real del catálogo validada."
- name: Instalar Trivy
shell: bash
run: |
set -euo pipefail
TRIVY_VERSION="0.74.0"
curl -sSfL \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
-o /tmp/trivy.tar.gz
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
chmod +x /tmp/trivy
/tmp/trivy version
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
# contenedores como root, falta de resource limits, falta de
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
# el pipeline mientras revisamos juntos qué hallazgos son reales.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
# Instalado directo con pip (no como container aparte): el runner de
# Gitea ejecuta cada job ya dentro de un contenedor propio que habla
# con el daemon Docker del host (sibling containers) -- un
# `docker run -v "${{ github.workspace }}:/src"` desde acá intenta
# montar una ruta que solo existe DENTRO del contenedor del job, no
# en el host real, y falla con "read-only file system". Evitamos por
# completo el problema corriendo Semgrep nativo, igual que
# gitleaks/trivy/syft/cosign.
# venv en vez de "pip3 install --break-system-packages": la imagen del
# runner ya trae paquetes de Python instalados por apt (ej. PyJWT)
# sin metadata compatible con pip, y pip aborta al intentar
# reemplazarlos ("RECORD file not found"). Un venv aislado evita
# tocar los paquetes del sistema por completo.
- name: Instalar Semgrep
shell: bash
run: |
set -euo pipefail
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
/tmp/semgrep-venv/bin/semgrep --version
# Modo auditoría: sin --error a propósito, Semgrep siempre termina
# con exit 0 aunque reporte hallazgos. Es la primera vuelta — se
# revisan los resultados en conjunto antes de decidir qué reglas
# deberían pasar a bloquear el pipeline más adelante.
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
shell: bash
run: |
set -euo pipefail
/tmp/semgrep-venv/bin/semgrep scan \
--config=p/typescript \
--config=p/react \
--config=p/nextjs \
--config=p/security-audit \
--json \
--output=semgrep-report.json \
"${APP_DIR}"
echo "=== Resumen Semgrep ==="
/tmp/semgrep-venv/bin/python -c "
import json
data = json.load(open('semgrep-report.json'))
results = data.get('results', [])
print(f'Hallazgos: {len(results)}')
for r in results:
print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\")
"
- name: Publicar reporte de Semgrep
if: always()
uses: actions/upload-artifact@v3
with:
name: semgrep-report
path: semgrep-report.json
if-no-files-found: ignore
- name: Login en Gitea Registry
uses: docker/login-action@v2
with:
registry: gitea.cruzcloud.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true
# push: false — la imagen se queda cargada en el daemon local (load:
# true) para poder escanearla con Trivy antes de subirla al registry.
- name: Construir Imagen
uses: docker/build-push-action@v4
with:
context: workloads/ecommerce/
push: false
load: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
# los reportes y decidimos, con calma, qué reglas deben bloquear.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# SBOM de la imagen que ya pasó el gate de CRITICAL — describe
# exactamente qué paquetes (y en qué versión) quedaron adentro.
# CycloneDX: formato más usado por herramientas de consulta/alertas
# de CVEs (ej. cruzar el SBOM contra un aviso nuevo tipo log4shell).
- name: Instalar Syft
shell: bash
run: |
set -euo pipefail
SYFT_VERSION="1.51.0"
curl -sSfL \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
-o /tmp/syft.tar.gz
tar -xzf /tmp/syft.tar.gz -C /tmp syft
chmod +x /tmp/syft
/tmp/syft version
- name: Generar SBOM (Syft)
shell: bash
run: |
set -euo pipefail
/tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.json
- name: Publicar SBOM
if: always()
uses: actions/upload-artifact@v3
with:
name: sbom-${{ steps.vars.outputs.VERSION }}
path: |
sbom.cdx.json
sbom.spdx.json
if-no-files-found: ignore
# Login ya se hizo arriba; recién acá se sube, después de que la
# imagen pasó el gate de CRITICAL.
- name: Subir Imagen al Registry
shell: bash
run: |
set -euo pipefail
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
docker push "${IMAGE_NAME}:latest"
- name: Instalar Cosign
shell: bash
run: |
set -euo pipefail
COSIGN_VERSION="3.1.3"
curl -sSfL \
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
-o /tmp/cosign
chmod +x /tmp/cosign
/tmp/cosign version
# Firma solo la versión (no :latest, que es un tag mutable y firmarlo
# pierde sentido en cuanto se vuelve a mover). --use-signing-config=false
# --tlog-upload=false: firma solo con el par de llaves propio, sin
# publicar metadata en el transparency log público de Sigstore — este
# registry es privado, no tiene sentido anunciar públicamente qué se
# firmó y cuándo.
- name: Firmar Imagen (Cosign)
shell: bash
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
/tmp/cosign sign \
--key env://COSIGN_PRIVATE_KEY \
--use-signing-config=false \
--tlog-upload=false \
--yes \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# Smoke test: confirma en el propio pipeline que la firma que se
# acaba de crear valida contra la llave pública commiteada en el
# repo (workloads/ecommerce/cosign.pub). Es la misma verificación
# que, más adelante, podría correr un admission controller en el
# cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md).
- name: Verificar Firma (smoke test)
shell: bash
run: |
set -euo pipefail
/tmp/cosign verify \
--key "${APP_DIR}/cosign.pub" \
--insecure-ignore-tlog=true \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# Evita que una ejecución antigua actualice frontend.yaml después de que
# ya exista un commit más reciente en main.
- name: Verificar promoción segura
id: promotion
shell: bash
run: |
set -euo pipefail
git fetch origin main
CURRENT_SHA="${{ github.sha }}"
REMOTE_SHA="$(git rev-parse origin/main)"
echo "Commit del pipeline: ${CURRENT_SHA}"
echo "Último commit remoto: ${REMOTE_SHA}"
if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then
echo "promote=true" >> "$GITHUB_OUTPUT"
echo "La imagen puede promocionarse."
else
echo "promote=false" >> "$GITHUB_OUTPUT"
echo "La imagen fue publicada, pero no se actualizará el manifiesto."
echo "Existe un commit más reciente y su pipeline debe realizar la promoción."
fi
- name: Actualizar Manifiesto GitOps (CD)
if: steps.promotion.outputs.promote == 'true'
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.vars.outputs.VERSION }}"
git config user.name "Cristian Felipe"
git config user.email "[email protected]"
# Sincronizamos el workspace temporal con main.
# No se fuerza ni se sobrescribe la rama remota.
git fetch origin main
git checkout -B main origin/main
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${MANIFEST_FILE}"
git add "${MANIFEST_FILE}"
if git diff --cached --quiet; then
echo "No hay cambios en el manifiesto."
exit 0
fi
git commit -m "chore: release ${VERSION} [skip ci]"
# Push normal: si main cambió durante el job, se detiene para evitar
# sobrescribir commits ajenos o promocionar una versión antigua.
git push origin HEAD:main
- name: Resumen del pipeline
if: always()
shell: bash
run: |
echo "========================================"
echo "ARI Shopping Frontend"
echo "Versión: ${{ steps.vars.outputs.VERSION }}"
echo "Commit: ${{ github.sha }}"
echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
echo "========================================"