name: Build and Push Medusa # 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". Las dos listas de # paths quedan duplicadas literalmente (mismo criterio que build.yaml). on: push: branches: - main paths: - 'workloads/commerce-backend/**' - '.gitea/workflows/build-medusa.yaml' pull_request: branches: - main paths: - 'workloads/commerce-backend/**' - '.gitea/workflows/build-medusa.yaml' permissions: contents: write packages: write jobs: # Corre en push y en pull_request, siempre antes que build. Mismo # criterio que el frontend: si hay un secreto commiteado, el job build # nunca arranca (needs: gitleaks). 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 - name: Escanear secretos en el árbol de archivos shell: bash run: | set -euo pipefail /tmp/gitleaks detect \ --source=workloads/commerce-backend \ --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 publicar Medusa needs: gitleaks if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 45 env: APP_DIR: workloads/commerce-backend MANIFEST_FILE: workloads/ecommerce/commerce/medusa.yaml IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-medusa steps: - name: Checkout del código uses: actions/checkout@v3 with: fetch-depth: 1 persist-credentials: true - name: Definir versión 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 } - 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 de Medusa (no la imagen). # Informativo por ahora -- mismo criterio que el frontend, se # revisan los hallazgos en conjunto antes de decidir qué bloquea. - name: Escanear manifiestos Kubernetes (Trivy IaC) shell: bash run: | set -euo pipefail /tmp/trivy config \ --severity CRITICAL,HIGH,MEDIUM \ --exit-code 0 \ "${MANIFEST_FILE}" # Mismo motivo que en build.yaml: el runner ejecuta el job ya # dentro de un contenedor propio que habla con el daemon Docker # del host (sibling containers, no Docker-in-Docker) -- correr # Semgrep como container aparte falla montando rutas que no # existen en el host real. Se instala nativo en un venv. - 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 # Ruleset adaptado al stack real de este repo: commerce-backend es # una API de Medusa v2 (Node.js/TypeScript sobre Express), no una # app Next.js con SSR -- confirmado en package.json (sin # dependencia de "next", react/react-dom solo llegan como # dependencia transitiva del admin-sdk de Medusa, no hay código de # UI propio en este directorio). Por eso se usa p/typescript en # vez de p/typescript + p/react + p/nextjs del frontend. # Modo auditoría, igual que el frontend: sin --error, primera # vuelta para revisar hallazgos antes de decidir qué bloquea. - 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/security-audit \ --config=p/owasp-top-ten \ --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 — igual que el frontend, la imagen se queda cargada # en el daemon local para poder escanearla con Trivy antes de # subirla al registry. - name: Construir Imagen uses: docker/build-push-action@v4 with: context: ${{ env.APP_DIR }}/ file: ${{ env.APP_DIR }}/Dockerfile 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. # --timeout 15m0s: el default de Trivy (5m) no alcanza para esta # imagen -- a diferencia del frontend, commerce-backend arrastra # el node_modules completo de Medusa v2 (framework + admin-sdk + # cli), muchos más archivos que escanear tanto para vulnerabilidades # como para el escaneo de secretos que Trivy corre por default # dentro de la imagen. Visto en vivo: "context deadline exceeded" # a los 4m52s con el timeout default, en un run donde el host # venía de terminar el docker build (ver hallazgo de rendimiento # de Gitea/runner en docs/playbooks). # --ignorefile: excepciones puntuales y documentadas (ver # workloads/commerce-backend/.trivyignore) para CVEs sin fix de # bajo riesgo disponible hoy. No debilita el gate en general -- # cualquier otra CRITICAL sigue bloqueando igual. - name: Escanear imagen (Trivy) — CRITICAL bloquea shell: bash run: | set -euo pipefail /tmp/trivy image \ --severity CRITICAL \ --exit-code 1 \ --ignore-unfixed \ --timeout 15m0s \ --ignorefile "${APP_DIR}/.trivyignore" \ "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" # HIGH solo informa por ahora — mismo criterio que el frontend. - name: Escanear imagen (Trivy) — HIGH informativo shell: bash run: | set -euo pipefail /tmp/trivy image \ --severity HIGH \ --exit-code 0 \ --ignore-unfixed \ --timeout 15m0s \ "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" - 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 # Mismo par de llaves que el frontend (COSIGN_PRIVATE_KEY / # COSIGN_PASSWORD ya existen como secrets a nivel de repo, no hace # falta un secret nuevo por app): una sola identidad de firma para # todo el registry de este lab. La llave pública se commitea en # cada directorio de app (workloads/commerce-backend/cosign.pub) # para que la verificación quede local a cada workflow, igual que # el frontend. - 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 }}" - 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 }}" - 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)" if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then echo "promote=true" >> "$GITHUB_OUTPUT" else echo "promote=false" >> "$GITHUB_OUTPUT" echo "Hay un commit más reciente; no se actualizará el manifiesto." fi - name: Actualizar manifiesto Medusa if: steps.promotion.outputs.promote == 'true' shell: bash run: | set -euo pipefail VERSION="${{ steps.vars.outputs.VERSION }}" MANIFEST="${{ env.MANIFEST_FILE }}" IMAGE="${{ env.IMAGE_NAME }}" git config user.name "gitea-actions" git config user.email "gitea-actions@cruzcloud.net" git fetch origin main git checkout -B main origin/main sed -i -E \ "s|(${IMAGE}:)v[0-9]+\.[0-9]+\.[0-9]+|\1${VERSION}|g" \ "${MANIFEST}" git add "${MANIFEST}" if git diff --cached --quiet; then echo "El manifiesto ya apunta a ${VERSION}." exit 0 fi git commit \ -m "chore(gitops): deploy Medusa ${VERSION} [skip ci]" git push origin HEAD:main - name: Resumen del pipeline if: always() shell: bash run: | echo "========================================" echo "ARI Shopping Commerce Backend (Medusa)" echo "Versión: ${{ steps.vars.outputs.VERSION }}" echo "Commit: ${{ github.sha }}" echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}" echo "========================================"