name: Build and Push Docs Portal # 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/docs-portal/docs/**' - 'workloads/docs-portal/mkdocs.yml' - 'workloads/docs-portal/requirements.txt' - 'workloads/docs-portal/Dockerfile' - '.gitea/workflows/deploy-docs.yaml' pull_request: branches: - main paths: - 'workloads/docs-portal/docs/**' - 'workloads/docs-portal/mkdocs.yml' - 'workloads/docs-portal/requirements.txt' - 'workloads/docs-portal/Dockerfile' - '.gitea/workflows/deploy-docs.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/docs-portal \ --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 Docs Portal needs: gitleaks if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 20 env: APP_DIR: workloads/docs-portal MANIFEST_FILE: workloads/docs-portal/deployment.yaml IMAGE_NAME: gitea.cruzcloud.net/devops/docs-portal 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 todo el directorio de la app (Deployment, Service, # Ingress y Dockerfile), no un único MANIFEST_FILE como el # frontend -- docs-portal tiene varios manifiestos K8s separados # (deployment.yaml, service.yaml, ingress.yaml) en vez de uno # solo, así que un único archivo dejaría fuera la mayoría del # directorio. Informativo por ahora, mismo criterio que el resto. - name: Escanear manifiestos Kubernetes (Trivy IaC) shell: bash run: | set -euo pipefail /tmp/trivy config \ --severity CRITICAL,HIGH,MEDIUM \ --exit-code 0 \ "${APP_DIR}" # Mismo motivo que en build.yaml: sibling containers, no # Docker-in-Docker -- Semgrep corre 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: docs-portal no tiene código de aplicación # propio (es contenido Markdown + configuración de MkDocs), así # que ni p/typescript ni p/react/p/nextjs del frontend aplican # acá. Se usa p/python -- el único código ejecutable real en este # directorio sería un hook/plugin de Python de MkDocs, si algún # día se agrega uno. Hoy no hay ningún archivo .py en # workloads/docs-portal, así que 0 hallazgos es el resultado # esperado, no un falso negativo -- se deja el stage para que # detecte código nuevo el día que se agregue, sin tener que # recordar volver a tocar el pipeline. Modo auditoría, igual que # el resto: no bloquea. - name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea shell: bash run: | set -euo pipefail /tmp/semgrep-venv/bin/semgrep scan \ --config=p/python \ --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 # mkdocs build --strict corre dentro del propio Dockerfile (stage # de build), así que un nav/link roto rompe este paso antes de # publicar. push: false / load: true -- igual que el frontend, la # imagen queda cargada localmente para escanearla con Trivy antes # de subirla. - 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: agregado preventivamente. Se vio en vivo, al # correr este mismo comando sin el flag en commerce-backend, que # el default de Trivy (5m) no alcanza en este host bajo carga # ("context deadline exceeded" a los 4m52s) -- la imagen de # docs-portal es chica, pero no cuesta nada blindar el mismo # comando en los tres pipelines. - name: Escanear imagen (Trivy) — CRITICAL bloquea shell: bash run: | set -euo pipefail /tmp/trivy image \ --severity CRITICAL \ --exit-code 1 \ --ignore-unfixed \ --timeout 15m0s \ "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" # HIGH solo informa por ahora — mismo criterio que el resto. - 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 y commerce-backend (secrets # ya existentes a nivel de repo). Llave pública commiteada en # workloads/docs-portal/cosign.pub. - 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 GitOps if: steps.promotion.outputs.promote == 'true' shell: bash run: | set -euo pipefail VERSION="${{ steps.vars.outputs.VERSION }}" 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: ${IMAGE_NAME}:).*|\1${VERSION}|g" \ "${MANIFEST_FILE}" git add "${MANIFEST_FILE}" if git diff --cached --quiet; then echo "El manifiesto ya apunta a ${VERSION}." exit 0 fi git commit \ -m "chore(gitops): deploy Docs Portal ${VERSION} [skip ci]" git push origin HEAD:main - name: Resumen del pipeline if: always() shell: bash run: | echo "========================================" echo "CruzCloud Lab Docs Portal" echo "Versión: ${{ steps.vars.outputs.VERSION }}" echo "Commit: ${{ github.sha }}" echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}" echo "========================================"