name: Build and deploy on: pull_request: push: branches: [main] workflow_dispatch: jobs: validate: name: Validate source runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Run fast regression checks run: | python3 -m py_compile app/*.py app/**/*.py ops/*.py deploy/*.py python3 -m unittest \ tests.test_dependency_lock \ tests.test_staging_readiness \ deploy.test_production_preflight \ tests.test_pricing \ tests.test_secrets -v sh -n infra/lock_dependencies.sh integration: name: Integration suite on a real stack needs: validate runs-on: ubuntu-latest timeout-minutes: 45 env: # The runner shares the host's Docker daemon, so every published port is # taken on the machine itself. Known occupants of that host: # 8000, 9443 Portainer (the Edge tunnel and its UI) # 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults) # 9000/9001 MinIO defaults elsewhere # This block avoids all of them. Ephemeral ports are not an option: the # published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP # when the containers start, so it has to be known beforehand. SITE_PORT: "28080" KANBAN_PORT: "28081" API_PORT: "28000" STORAGE_PORT: "29000" STORAGE_CONSOLE_PORT: "29001" # Presigned URLs are signed against this endpoint, so it must be reachable # by whoever follows them. The suites run inside the network, so it has to # be the service name, not a published port on the host. S3_PUBLIC_ENDPOINT: http://storage:9000 PUBLIC_ORIGIN: http://site ALLOWED_HOSTS: localhost,127.0.0.1,site,kanban ALLOWED_ORIGINS: http://site,http://kanban,http://localhost:28080,http://localhost:28081 COMPOSE: docker compose -f compose.local.yaml steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # py_compile cannot see an unresolved name, and the four unit tests above # never start the application. A missing import in local/auth.py therefore # reached production and returned 500 on every session, login and # registration. These suites exercise the running stack and would have # failed on it immediately. - name: Start the stack run: | $COMPOSE up --build -d --wait --wait-timeout 600 $COMPOSE ps # Run inside the stack's own network. The runner is itself a container, so # ports published on the host's loopback are in a different namespace and # unreachable from here. SITE_HOST_HEADER keeps the Host the gateway and # TrustedHostMiddleware expect, so the configuration under test is the same # one a developer exercises on localhost. - name: API and workflow regressions run: | for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test print_file_test; do echo "--- $suite" $COMPOSE exec -T \ -e SITE_BASE_URL=http://site \ -e SITE_HOST_HEADER=localhost \ api python -m "tests.$suite" done # Need Pillow and httpx, which only the application image has. The raster # check needs PyMuPDF as well and skips here; run it locally when changing # the generator's geometry. The provider suites use a fake transport: they # prove the documented contract, not the integration. - name: Print-file geometry and provider adapters run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review -v - name: Runtime and retention regressions run: | $COMPOSE exec -T api python -m tests.retention_test $COMPOSE exec -T api python -m tests.runtime_security_test $COMPOSE exec -T api python -m tests.tiny_oauth_test # Run Chrome on the Compose network. It must resolve the same storage:9000 # hostname used in presigned URLs, and absence of Chrome must fail CI. - name: Browser regressions run: | $COMPOSE build browser-tests $COMPOSE run --rm --no-deps browser-tests sh -ec \ 'node tests/artwork_browser_test.mjs && node tests/browser_test.mjs' - name: Diagnostics on failure if: failure() run: | $COMPOSE ps || true $COMPOSE logs --tail 200 api worker site kanban || true - name: Tear down if: always() run: $COMPOSE down -v || true scan: name: Secret scan and release gate needs: validate runs-on: ubuntu-latest timeout-minutes: 30 env: TRIVY_IMAGE: ${{ vars.TRIVY_IMAGE }} ENFORCE_PRODUCTION_PREFLIGHT: ${{ vars.ENFORCE_PRODUCTION_PREFLIGHT }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # Blocking. A credential committed by accident must never reach the # registry or the deployed stack, and the repository is clean today, so # this gate costs nothing until it is actually needed. - name: Secret scan run: | image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}" docker run --rm -v "$PWD:/src:ro" "$image" \ fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \ --no-progress /src # Advisory while the provider adapters are fake. This is the only copy of # the gate: set ENFORCE_PRODUCTION_PREFLIGHT=true and a blocked preflight # fails this job, which stops images from being published. - name: Production source preflight run: | set +e python3 deploy/production_preflight.py --source-only verdict=$? set -e if [ "$verdict" -eq 0 ]; then echo "Source preflight passes." exit 0 fi if [ "${ENFORCE_PRODUCTION_PREFLIGHT:-false}" = "true" ]; then echo "::error::Source preflight blocked the release." exit "$verdict" fi echo "::warning::Source preflight reports blockers (advisory; set ENFORCE_PRODUCTION_PREFLIGHT=true to gate)." # Every push to main that passes validation, the integration suite and the # scans publishes images. Production changes only when someone pulls and # redeploys the stack in Portainer; a manual run of this workflow also calls # the Portainer webhook when one is configured. publish-and-deploy: name: Publish images needs: [validate, integration, scan] if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') runs-on: ubuntu-latest timeout-minutes: 45 env: TRIVY_IMAGE: ${{ vars.TRIVY_IMAGE }} PYTHON_BASE_IMAGE: ${{ vars.PYTHON_BASE_IMAGE }} NGINX_BASE_IMAGE: ${{ vars.NGINX_BASE_IMAGE }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Sign in to the Gitea Container Registry env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | test -n "$REGISTRY_USERNAME" test -n "$REGISTRY_TOKEN" echo "$REGISTRY_TOKEN" | docker login gitea.blyzer.com.br \ --username "$REGISTRY_USERNAME" --password-stdin - name: Build API run: | image="gitea.blyzer.com.br/blyzer/dtf-api" # The Dockerfiles pin digests themselves; these variables let a base be # moved forward without editing the repository. --pull is intentionally # absent: a digest already names one immutable image. set -- [ -n "$PYTHON_BASE_IMAGE" ] && set -- --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" docker build --file deploy/Dockerfile.api "$@" \ --build-arg VCS_REF="${{ gitea.sha }}" \ --tag "$image:latest" --tag "$image:${{ gitea.sha }}" . - name: Build web run: | image="gitea.blyzer.com.br/blyzer/dtf-web" set -- [ -n "$PYTHON_BASE_IMAGE" ] && set -- --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" [ -n "$NGINX_BASE_IMAGE" ] && set -- "$@" --build-arg NGINX_BASE_IMAGE="$NGINX_BASE_IMAGE" docker build --file deploy/Dockerfile.web "$@" \ --build-arg VCS_REF="${{ gitea.sha }}" \ --tag "$image:latest" --tag "$image:${{ gitea.sha }}" . # CRITICAL blocks, HIGH is reported. Both images carry zero CRITICAL after # the base pinning and OS upgrades, so this gate holds the line already # reached. The remaining HIGH findings have no upstream fix, so failing on # them would stop releases without making anything safer. - name: Image vulnerabilities run: | image="${TRIVY_IMAGE:-aquasec/trivy:0.58.1}" failed=0 for target in \ "gitea.blyzer.com.br/blyzer/dtf-api:${{ gitea.sha }}" \ "gitea.blyzer.com.br/blyzer/dtf-web:${{ gitea.sha }}"; do echo "--- $target (HIGH, reported)" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ image --image-src docker --scanners vuln --severity HIGH --no-progress \ --format table --exit-code 0 "$target" || echo "::warning::Could not scan $target for HIGH findings" echo "--- $target (CRITICAL, blocking)" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ image --image-src docker --scanners vuln --severity CRITICAL --no-progress \ --format table --exit-code 1 "$target" || failed=1 done if [ "$failed" -ne 0 ]; then echo "::error::A CRITICAL vulnerability was found in a release image." exit 1 fi - name: Publish validated images run: | for name in dtf-api dtf-web; do image="gitea.blyzer.com.br/blyzer/$name" docker push "$image:${{ gitea.sha }}" docker push "$image:latest" done - name: Trigger Portainer redeployment if: gitea.event_name == 'workflow_dispatch' env: PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }} run: | if [ -z "$PORTAINER_WEBHOOK" ]; then echo "No PORTAINER_WEBHOOK configured; redeploy the stack in Portainer." exit 0 fi curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"