All checks were successful
Build and deploy / Validate source (push) Successful in 1m28s
Build and deploy / Integration suite on a real stack (push) Successful in 4m3s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
235 lines
9.8 KiB
YAML
235 lines
9.8 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; do
|
|
echo "--- $suite"
|
|
$COMPOSE exec -T \
|
|
-e SITE_BASE_URL=http://site \
|
|
-e SITE_HOST_HEADER=localhost \
|
|
api python -m "tests.$suite"
|
|
done
|
|
|
|
- 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
|
|
|
|
# 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
|
|
|
|
# Keep push feedback advisory while the provider adapters are fake.
|
|
# The manual release job enforces the source preflight unconditionally.
|
|
# ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too.
|
|
- 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)."
|
|
|
|
publish-and-deploy:
|
|
name: Publish images and notify Portainer
|
|
needs: [validate, integration, scan]
|
|
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
|
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: Require production readiness
|
|
env:
|
|
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
|
run: |
|
|
python3 deploy/production_preflight.py --source-only
|
|
test -n "$PORTAINER_WEBHOOK"
|
|
- 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
|
|
env:
|
|
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
|
run: |
|
|
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
|