All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 3m38s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m2s
A backup service runs pg_dump every day at 03:00 Brasília, checks the archive, encrypts it with age to a public key and uploads it with a token for that bucket only. The server cannot read or delete backups: the private key stays with the owner, the bucket's lifecycle rule expires copies and its lock stops early deletion. Each run is recorded and shown on the Kanban's Integrations tab. tests/backup_test.py backs up, restores into a scratch database and compares the rows in CI. Setup and restore: docs/BACKUP.md. 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 -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"
|