All checks were successful
Build and deploy / Validate source (push) Successful in 1m23s
Build and deploy / Integration suite on a real stack (push) Successful in 3m13s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m25s
The price depends on the grade, which the browser worked out and the API took on trust. Before a cart is approved at checkout the API now recomputes it from the uploaded files by the Site's own rules: the pixel size in a PNG, JPG or WebP header across the printed width (rotation included), and the area-weighted DPI of the images placed in a PDF of up to 150 MB, 300 for vectors. Sheets take the worst grade, artworks the average. A claim more than 2 points above the file's grade, or a discount on a file the server cannot grade, waits for an operator, with the reason on the Kanban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
12 KiB
YAML
270 lines
12 KiB
YAML
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 tests.test_large_files tests.test_grade_check -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
|
|
$COMPOSE exec -T backup python -m tests.backup_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 backup 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}"
|
|
# One database download for the four scans, kept in a volume between
|
|
# runs and retried: a failed download from the mirror used to fail
|
|
# the gate as if a CRITICAL vulnerability had been found.
|
|
trivy() { docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
|
-v dtf-trivy-cache:/root/.cache/trivy "$image" "$@"; }
|
|
for attempt in 1 2 3; do
|
|
trivy image --download-db-only --no-progress && break
|
|
if [ "$attempt" -eq 3 ]; then
|
|
echo "::error::The vulnerability database could not be downloaded; the release images were not scanned."
|
|
exit 1
|
|
fi
|
|
echo "Database download failed (attempt $attempt); retrying in 30 s."
|
|
sleep 30
|
|
done
|
|
# Findings exit 5; any other failure means the scan did not run.
|
|
found=0; broken=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)"
|
|
trivy image --skip-db-update --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)"
|
|
set +e
|
|
trivy image --skip-db-update --image-src docker --scanners vuln --severity CRITICAL --no-progress \
|
|
--format table --exit-code 5 "$target"
|
|
verdict=$?
|
|
set -e
|
|
if [ "$verdict" -eq 5 ]; then found=1
|
|
elif [ "$verdict" -ne 0 ]; then broken=1; echo "::error::The CRITICAL scan of $target did not run (exit $verdict)."
|
|
fi
|
|
done
|
|
if [ "$found" -ne 0 ]; then
|
|
echo "::error::A CRITICAL vulnerability was found in a release image."
|
|
exit 1
|
|
fi
|
|
if [ "$broken" -ne 0 ]; then
|
|
echo "::error::A release image could not be scanned; nothing is published unscanned."
|
|
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"
|