ci: require manual gated releases from main
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

This commit is contained in:
Cauê Faleiros
2026-09-23 11:27:18 -03:00
parent 24013458c9
commit cfcbe545f1
9 changed files with 137 additions and 93 deletions

View File

@@ -48,6 +48,9 @@ jobs:
# 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
@@ -70,7 +73,7 @@ jobs:
# 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; do
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 \
@@ -83,35 +86,13 @@ jobs:
$COMPOSE exec -T api python -m tests.retention_test
$COMPOSE exec -T api python -m tests.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.
# 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: |
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
# Chrome runs here, in the runner container, and reaches the stack only
# through ports published on the host. When the runner is itself a
# container those are in another namespace, so check before running
# rather than failing with a bare connection error. See ROADMAP 5.10.
if ! wget -q -T 5 -O /dev/null "http://localhost:${SITE_PORT}/health"; then
echo "::warning::Stack not reachable from the runner; browser regressions were NOT run."
exit 0
fi
echo "Using $CHROME_BIN"
node tests/artwork_browser_test.mjs
node tests/browser_test.mjs
$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()
@@ -145,13 +126,9 @@ jobs:
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
--no-progress /src
# docs/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.
# 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
@@ -171,7 +148,7 @@ jobs:
publish-and-deploy:
name: Publish images and notify Portainer
needs: [validate, integration, scan]
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -181,6 +158,12 @@ jobs:
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 }}
@@ -190,7 +173,7 @@ jobs:
test -n "$REGISTRY_TOKEN"
echo "$REGISTRY_TOKEN" | docker login gitea.blyzer.com.br \
--username "$REGISTRY_USERNAME" --password-stdin
- name: Build and publish API
- name: Build API
run: |
image="gitea.blyzer.com.br/blyzer/dtf-api"
# The Dockerfiles pin digests themselves; these variables let a base be
@@ -201,9 +184,7 @@ jobs:
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
- name: Build web
run: |
image="gitea.blyzer.com.br/blyzer/dtf-web"
set --
@@ -212,8 +193,6 @@ jobs:
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
@@ -227,25 +206,29 @@ jobs:
"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 \
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 --scanners vuln --severity CRITICAL --no-progress \
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 published image."
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: |
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"

View File

@@ -23,9 +23,9 @@ x-app: &app
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
# The browser reaches the API through the Site gateway, so the published
# Site/Kanban origins must be accepted or every write is rejected 403.
PUBLIC_ORIGIN: http://localhost:${SITE_PORT:-8080}
ALLOWED_HOSTS: localhost,127.0.0.1
ALLOWED_ORIGINS: http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:-http://localhost:${SITE_PORT:-8080}}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}}
COOKIE_SECURE: "false"
PAYMENT_ADAPTER: fake
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
@@ -208,6 +208,28 @@ services:
timeout: 3s
retries: 12
browser-tests:
profiles: [ci]
build:
context: .
dockerfile: infra/Dockerfile.browser-tests
environment:
CHROME_BIN: /usr/bin/chromium
CHROME_NO_SANDBOX: "1"
CHROME_TRUST_TEST_ORIGINS: "1"
SITE_BROWSER_ORIGIN: http://site
KANBAN_BROWSER_ORIGIN: http://kanban
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-operator@example.test}
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:-local-operator-only}
shm_size: 1gb
networks: [local]
depends_on:
site: {condition: service_healthy}
kanban: {condition: service_healthy}
storage: {condition: service_healthy}
security_opt: [no-new-privileges:true]
cap_drop: [ALL]
volumes:
postgres-data:
storage-data:

View File

@@ -1,8 +1,27 @@
import unittest
from pathlib import Path
from .production_preflight import config_errors, source_errors
class ReleaseWorkflowTests(unittest.TestCase):
def test_main_push_cannot_publish_and_manual_release_is_gated(self):
workflow = (Path(__file__).resolve().parents[1] /
'.gitea/workflows/deploy.yml').read_text()
release = workflow.split(' publish-and-deploy:\n', 1)[1]
self.assertIn("if: gitea.event_name == 'workflow_dispatch' && "
"gitea.ref == 'refs/heads/main'", release)
preflight = release.index('python3 deploy/production_preflight.py --source-only')
webhook = release.index('test -n "$PORTAINER_WEBHOOK"')
scan = release.index('- name: Image vulnerabilities')
publish = release.index('- name: Publish validated images')
redeploy = release.index('- name: Trigger Portainer redeployment')
self.assertLess(preflight, scan)
self.assertLess(webhook, scan)
self.assertLess(scan, publish)
self.assertLess(publish, redeploy)
def valid_config():
digest = '1' * 64
values = {

View File

@@ -17,8 +17,9 @@ Cloudflare R2, so MinIO is not part of this stack.
The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request
runs static validation, the integration suite against a real stack, and a secret
scan. A push to `main` then builds the production images, publishes both `latest`
and the full commit SHA, reports their vulnerabilities, and calls Portainer.
scan. A push to `main` does not publish or deploy. A manual workflow run on
`main` repeats those checks, builds and scans the images, then publishes both
`latest` and the full commit SHA and calls Portainer.
What actually gates a deployment:
@@ -26,17 +27,19 @@ What actually gates a deployment:
|---|---|
| `py_compile` and the unit tests | yes |
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
| Browser suites | only when the runner has Chrome; otherwise warns and continues |
| Browser suites | yes; Chrome runs in the Compose test container |
| Trivy secret scan (HIGH/CRITICAL) | yes |
| Source preflight (`deploy/production_preflight.py --source-only`) | only when `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
| Trivy image vulnerabilities, CRITICAL | yes |
| Trivy image vulnerabilities, HIGH | no — reported after the build |
| Trivy image vulnerabilities, HIGH | no — reported before publication |
| Configured Portainer webhook | yes for manual release |
The source preflight is advisory by default because it refuses a release while
the payment and messaging adapters are fake, which is the deliberate state the
stack runs in today. Enforcing it now would block every deployment. Set the
repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once real adapters
land, and it becomes a hard gate.
The source preflight refuses a release while the payment and messaging adapters
are fake. It remains advisory on push checks so development can continue, but a
manual release is blocked until those adapters are replaced. Set the repository
variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also
fail on those blockers. Before a manual release, run the full configuration
preflight below against the actual Portainer values; CI checks source only.
CRITICAL image findings block. Both images carry none: the bases are pinned by
digest, both Dockerfiles upgrade their OS packages, and the web image moved off
@@ -60,10 +63,10 @@ Repository secrets:
- `REGISTRY_USERNAME` and `REGISTRY_TOKEN` — package write credentials.
- `PORTAINER_WEBHOOK` — webhook generated by the `dtf-cloud` Portainer stack.
The webhook is called only after the gating checks in the table above pass.
The webhook is called only after the release gates above pass.
`ENFORCE_PRODUCTION_PREFLIGHT` and `TRIVY_IMAGE` are optional repository
variables; without them the preflight is advisory and a pinned default scanner
image is used.
variables; the former affects push checks and the latter defaults to a pinned
scanner image.
## 2. One-time Portainer resources
@@ -124,10 +127,13 @@ The `db-init` service completing and stopping is expected. The other six
services must be healthy. A failed `db-init` task or an unhealthy service blocks
acceptance.
## 4. Normal deployment
## 4. Manual deployment
Push to `main`. Gitea validates, tests, scans, publishes these images, and calls
the webhook:
Push the reviewed commit to `main` and wait for its validation workflow to pass.
After validating the actual Portainer configuration with the full preflight in
section 3, use Gitea Actions to manually run **Build and deploy** on `main` at
that commit. The workflow repeats validation, tests and scans, then publishes
these images and calls the webhook:
```text
gitea.blyzer.com.br/blyzer/dtf-api:latest

View File

@@ -14,6 +14,9 @@ kept through the approved order; 4.6 now pages pending and approved unpaid
quotes, including a tested 101st pending quote. Operational entrypoints in
5.12 are repaired and locally exercised. Image decoding, mixed-sheet grading,
rotation-sensitive DPI, and PDF page geometry are corrected in 3.9/4.4.
`main` pushes now validate without publishing; manual release requires a passing
source preflight. The containerized browser gate passes locally, pending a Gitea
runner run.
Next address the upload/scanner safety gate and unsupported PDF image evidence.
The customer/API upload admission now stops above the scanner's effective limit
before transfer; the 5 GiB large-file product path still needs agreement and
@@ -598,14 +601,9 @@ print-file evidence still need correction before this item can close.
integration job should run `down -v` before `up` — or a dedicated step should
apply `schema.sql` twice to a fresh database, proving both a first install and
a re-run.
- `[ ]` 5.10 — The browser suites do not run in CI. Chrome runs in the runner
container and can only reach the stack through ports published on the host, which
is a different network namespace when the runner is itself a container. The API,
workflow, security, scanning, retention and runtime suites were moved inside the
stack's network and do gate. The browser suites are the only coverage for the
artwork editor and the full customer journey, so they need either Chrome in a
container on that network, or a runner with host networking. Until then they gate
locally only, and CI warns when it skips them.
- `[ ]` 5.10 — The browser suites were moved into a Chrome container on the
Compose network and made required in CI. Confirm the complete checkout journey
passes in that topology and on the actual Gitea runner before closing this item.
- `[ ]` 5.9 — `local/browser_test.mjs` failed once and passed on an immediate
re-run, with no code change in between (2026-09-21). It is a deploy gate when the
runner has Chrome, so an intermittent failure there blocks releases for no reason.
@@ -627,10 +625,12 @@ print-file evidence still need correction before this item can close.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file.
- `[ ]` 5.14 — Promote and verify one immutable release. Scan before publishing
mutable tags, make the source preflight validate the active stack, require the
browser tests to run, test clean install and upgrade, and check application
readiness after Portainer redeploys. Isolate concurrent CI stacks.
- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes
now run checks only; manual dispatch requires source preflight and a configured
webhook, and scans images before publishing. Still make the full preflight
validate the active stack, deploy the tested immutable image references, test
clean install and upgrade, check application readiness after Portainer
redeploys, and isolate concurrent CI stacks.
---

View File

@@ -132,15 +132,12 @@ change when the advisory database or selected base digest changes.
The files in `deploy/` and `.gitea/workflows/` are a guarded delivery mechanism,
not an approval to operate the current application on the public internet.
Corrected 2026-09-21: an earlier version of this section described gates the
workflow did not contain. The workflow now runs static validation, the
integration suite against a live stack, and a blocking Trivy secret scan before
publishing. The source preflight is advisory unless
`ENFORCE_PRODUCTION_PREFLIGHT` is set, and image vulnerabilities are reported
rather than enforced, because the current bases carry HIGH/CRITICAL findings
with no upstream fix. Base images are still mutable tags, not digests.
`PORTAINER.md` holds the authoritative table of what gates and what does not.
It publishes both `latest` and the full commit SHA, then calls the Portainer
Updated 2026-09-23: pushes to `main` run checks only. A manual workflow run on
`main` requires the source preflight and a configured Portainer webhook before
building. It scans built images before publication; CRITICAL findings block and
HIGH findings are reported. The browser suites run in a required Compose Chrome
container. `PORTAINER.md` holds the authoritative gate table. A permitted release
publishes both `latest` and the full commit SHA, then calls the Portainer
webhook. Application/provider secrets are created directly as versioned external
Swarm secrets and never cross the workflow. Rollback selects the prior commit SHA
in Portainer and does not roll back the database.

View File

@@ -0,0 +1,13 @@
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
COPY tests /workspace/tests
COPY web /workspace/web
RUN mkdir -p /workspace/output/local \
&& chown -R node:node /workspace/output
USER node
ENV CHROME_BIN=/usr/bin/chromium

View File

@@ -1,8 +1,8 @@
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
server {
listen 80;
server_name localhost;
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
server_name localhost site kanban;
if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; }
root /usr/share/nginx/html;
index ${WEB_INDEX};
add_header X-Content-Type-Options nosniff always;
@@ -23,8 +23,8 @@ server {
}
server {
listen 81;
server_name localhost;
if ($host !~ ^(localhost|127\.0\.0\.1)$) { return 400; }
server_name localhost site kanban;
if ($host !~ ^(localhost|127\.0\.0\.1|site|kanban)$) { return 400; }
client_max_body_size 2m;
location / {
limit_req zone=api_limit burst=100 nodelay;

View File

@@ -15,6 +15,10 @@ const profile=await mkdtemp(tmpdir()+'/dtf-browser-');
const chrome=spawn(process.env.CHROME_BIN||'/usr/bin/google-chrome-stable',[
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
...(process.env.CHROME_NO_SANDBOX==='1'?['--no-sandbox']:[]),
// Production uses HTTPS; internal Compose HTTP names need a secure context
// for Web Crypto during the upload and checkout journey.
...(process.env.CHROME_TRUST_TEST_ORIGINS==='1'
? ['--unsafely-treat-insecure-origin-as-secure=http://site,http://kanban'] : []),
'--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
],{stdio:['ignore','ignore','pipe']});
const pause=ms=>new Promise(r=>setTimeout(r,ms));