The production images built on mutable tags with --pull, so the same commit could produce different bases, and neither Dockerfile upgraded its OS packages even though the local ones did. The published API image carried 56 HIGH and 3 CRITICAL findings, 15 of them with an upstream fix available. Pin both bases by digest and upgrade OS packages in the production images. That removes all 3 CRITICAL and 13 of the 15 fixable findings. The remaining two, msgpack and setuptools, come from a third-party SBOM; neither package is importable or listed by pip in the built image, which I confirmed rather than taking the previous report's word for it. The web image could not be fixed this way: the official 1.28 line pins nginx=1.28.3-r1 in /etc/apk/world, so apk upgrade leaves five HIGH findings in place even though Alpine ships 1.28.3-r7. Moving to nginx:alpine (1.31.6) clears them completely; 1.29-alpine scans worse, at 37 HIGH. Same uid 101 and the same template entrypoint, and the local images now use the same pinned bases so the integration suite exercises what ships. Full suite passes on nginx 1.31.6, including the browser end-to-end. With both images at zero CRITICAL, the image scan now blocks on CRITICAL and reports HIGH, instead of reporting everything. PYTHON_BASE_IMAGE and NGINX_BASE_IMAGE are wired through to the builds so a base can move forward without editing the repository, which is what PORTAINER.md already promised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.9 KiB
YAML
222 lines
8.9 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 local/*.py deploy/*.py
|
|
python3 -m unittest \
|
|
local.test_dependency_lock \
|
|
local.test_staging_readiness \
|
|
deploy.test_production_preflight \
|
|
local.test_pricing \
|
|
local.test_secrets -v
|
|
sh -n local/lock_dependencies.sh
|
|
|
|
integration:
|
|
name: Integration suite on a real stack
|
|
needs: validate
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 45
|
|
env:
|
|
SITE_PORT: "8080"
|
|
KANBAN_PORT: "8081"
|
|
API_PORT: "8000"
|
|
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
|
|
|
|
- name: API and workflow regressions
|
|
run: |
|
|
python3 -m local.smoke_test
|
|
python3 -m local.workflow_test
|
|
python3 -m local.security_test
|
|
python3 -m local.scanning_test
|
|
|
|
- name: Runtime and retention regressions
|
|
run: |
|
|
$COMPOSE exec -T api python -m local.retention_test
|
|
$COMPOSE exec -T api python -m local.runtime_security_test
|
|
|
|
# These need a real Chrome. They are the only coverage for the artwork
|
|
# editor and the full customer journey, so install google-chrome-stable
|
|
# (or set CHROME_BIN) on the runner to make them gate deployments. The
|
|
# suites above stay hard gates either way.
|
|
- name: Browser regressions
|
|
run: |
|
|
for candidate in "$CHROME_BIN" /usr/bin/google-chrome-stable \
|
|
/usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser; do
|
|
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
|
|
export CHROME_BIN="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
if [ ! -x "${CHROME_BIN:-}" ]; then
|
|
echo "::warning::No Chrome on this runner; browser regressions were NOT run."
|
|
echo "Install google-chrome-stable or set CHROME_BIN to gate on them."
|
|
exit 0
|
|
fi
|
|
echo "Using $CHROME_BIN"
|
|
node local/artwork_browser_test.mjs
|
|
node local/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
|
|
|
|
# PORTAINER.md described this as blocking publication. It never ran at
|
|
# all, and turning it on unconditionally would block every deploy: the
|
|
# source preflight refuses a release while the payment and messaging
|
|
# adapters are fake, which is the deliberate state the stack runs in
|
|
# today. So its verdict is always printed, and enforcement is opt-in.
|
|
# Set the repository variable ENFORCE_PRODUCTION_PREFLIGHT to "true" once
|
|
# real adapters land, and this becomes the gate the documentation claims.
|
|
- 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 == 'push' && 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: 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 and publish 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 }}" .
|
|
docker push "$image:latest"
|
|
docker push "$image:${{ gitea.sha }}"
|
|
- name: Build and publish 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 }}" .
|
|
docker push "$image:latest"
|
|
docker push "$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 --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 --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 published image."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Trigger Portainer redeployment
|
|
env:
|
|
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
|
run: |
|
|
if [ -z "$PORTAINER_WEBHOOK" ]; then
|
|
echo "PORTAINER_WEBHOOK is not configured; images were published but deployment was skipped."
|
|
exit 0
|
|
fi
|
|
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
|