ci: publish images from every green push to main
The release job enforced the production source preflight, which blocks while the payment and messaging adapters are fake. They still are, by design, so since 2026-09-23 no release could succeed and production kept running older images while main moved on. Pushes to main that pass validation, the integration suite and the scans now build, scan and publish the images. Nothing is deployed automatically: production changes when the stack is pulled and redeployed in Portainer. A manual run also calls the Portainer webhook when one is configured. The preflight stays in the scan job, advisory unless ENFORCE_PRODUCTION_PREFLIGHT is true, in which case a blocked preflight stops publishing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,9 +134,9 @@ jobs:
|
|||||||
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
|
fs --scanners secret --exit-code 1 --severity HIGH,CRITICAL \
|
||||||
--no-progress /src
|
--no-progress /src
|
||||||
|
|
||||||
# Keep push feedback advisory while the provider adapters are fake.
|
# Advisory while the provider adapters are fake. This is the only copy of
|
||||||
# The manual release job enforces the source preflight unconditionally.
|
# the gate: set ENFORCE_PRODUCTION_PREFLIGHT=true and a blocked preflight
|
||||||
# ENFORCE_PRODUCTION_PREFLIGHT can make push checks fail on blockers too.
|
# fails this job, which stops images from being published.
|
||||||
- name: Production source preflight
|
- name: Production source preflight
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
@@ -153,10 +153,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "::warning::Source preflight reports blockers (advisory; set ENFORCE_PRODUCTION_PREFLIGHT=true to gate)."
|
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:
|
publish-and-deploy:
|
||||||
name: Publish images and notify Portainer
|
name: Publish images
|
||||||
needs: [validate, integration, scan]
|
needs: [validate, integration, scan]
|
||||||
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
|
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
@@ -166,12 +170,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
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
|
- name: Sign in to the Gitea Container Registry
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
@@ -236,7 +234,12 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Trigger Portainer redeployment
|
- name: Trigger Portainer redeployment
|
||||||
|
if: gitea.event_name == 'workflow_dispatch'
|
||||||
env:
|
env:
|
||||||
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
PORTAINER_WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK }}
|
||||||
run: |
|
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"
|
curl --fail --silent --show-error --max-time 30 --request POST "$PORTAINER_WEBHOOK"
|
||||||
|
|||||||
@@ -5,21 +5,24 @@ from .production_preflight import config_errors, source_errors
|
|||||||
|
|
||||||
|
|
||||||
class ReleaseWorkflowTests(unittest.TestCase):
|
class ReleaseWorkflowTests(unittest.TestCase):
|
||||||
def test_main_push_cannot_publish_and_manual_release_is_gated(self):
|
def test_only_checked_main_publishes_and_pushes_never_deploy(self):
|
||||||
workflow = (Path(__file__).resolve().parents[1] /
|
workflow = (Path(__file__).resolve().parents[1] /
|
||||||
'.gitea/workflows/deploy.yml').read_text()
|
'.gitea/workflows/deploy.yml').read_text()
|
||||||
release = workflow.split(' publish-and-deploy:\n', 1)[1]
|
checks, release = workflow.split(' publish-and-deploy:\n', 1)
|
||||||
self.assertIn("if: gitea.event_name == 'workflow_dispatch' && "
|
# Publishing waits for every check, and only ever happens from main.
|
||||||
"gitea.ref == 'refs/heads/main'", release)
|
self.assertIn('needs: [validate, integration, scan]', release)
|
||||||
preflight = release.index('python3 deploy/production_preflight.py --source-only')
|
self.assertIn("if: gitea.ref == 'refs/heads/main' && ", release)
|
||||||
webhook = release.index('test -n "$PORTAINER_WEBHOOK"')
|
# The source preflight lives in the scan job and can be made blocking.
|
||||||
|
self.assertIn('python3 deploy/production_preflight.py --source-only', checks)
|
||||||
|
self.assertIn('ENFORCE_PRODUCTION_PREFLIGHT', checks)
|
||||||
|
# Images are scanned before they are pushed, and a push to main only
|
||||||
|
# publishes: redeployment is Portainer's (or a manual run's) decision.
|
||||||
scan = release.index('- name: Image vulnerabilities')
|
scan = release.index('- name: Image vulnerabilities')
|
||||||
publish = release.index('- name: Publish validated images')
|
publish = release.index('- name: Publish validated images')
|
||||||
redeploy = release.index('- name: Trigger Portainer redeployment')
|
redeploy = release.index('- name: Trigger Portainer redeployment')
|
||||||
self.assertLess(preflight, scan)
|
|
||||||
self.assertLess(webhook, scan)
|
|
||||||
self.assertLess(scan, publish)
|
self.assertLess(scan, publish)
|
||||||
self.assertLess(publish, redeploy)
|
self.assertLess(publish, redeploy)
|
||||||
|
self.assertIn("if: gitea.event_name == 'workflow_dispatch'", release[redeploy:])
|
||||||
|
|
||||||
|
|
||||||
def valid_config():
|
def valid_config():
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ Cloudflare R2, so MinIO is not part of this stack.
|
|||||||
|
|
||||||
The single workflow is `.gitea/workflows/deploy.yml`. Every push and pull request
|
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
|
runs static validation, the integration suite against a real stack, and a secret
|
||||||
scan. A push to `main` does not publish or deploy. A manual workflow run on
|
scan. When all of them pass on a push to `main`, the images are built, scanned
|
||||||
`main` repeats those checks, builds and scans the images, then publishes both
|
and published as both `latest` and the full commit SHA. Nothing is deployed:
|
||||||
`latest` and the full commit SHA and calls Portainer.
|
production changes when someone pulls and redeploys the stack in Portainer. A
|
||||||
|
manual workflow run on `main` does the same and also calls the Portainer
|
||||||
|
webhook, if `PORTAINER_WEBHOOK` is configured.
|
||||||
|
|
||||||
What actually gates a deployment:
|
What actually gates a deployment:
|
||||||
|
|
||||||
@@ -29,16 +31,18 @@ What actually gates a deployment:
|
|||||||
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
|
| Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) | yes |
|
||||||
| Browser suites | yes; Chrome runs in the Compose test container |
|
| Browser suites | yes; Chrome runs in the Compose test container |
|
||||||
| Trivy secret scan (HIGH/CRITICAL) | yes |
|
| Trivy secret scan (HIGH/CRITICAL) | yes |
|
||||||
| Source preflight (`deploy/production_preflight.py --source-only`) | yes for manual release; advisory on pushes unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true` |
|
| Source preflight (`deploy/production_preflight.py --source-only`) | advisory unless `ENFORCE_PRODUCTION_PREFLIGHT` is `true`, which blocks publishing |
|
||||||
| Trivy image vulnerabilities, CRITICAL | yes |
|
| Trivy image vulnerabilities, CRITICAL | yes |
|
||||||
| Trivy image vulnerabilities, HIGH | no — reported before publication |
|
| Trivy image vulnerabilities, HIGH | no — reported before publication |
|
||||||
| Configured Portainer webhook | yes for manual release |
|
| Configured Portainer webhook | no — called on manual runs when present; otherwise redeploy in Portainer |
|
||||||
|
|
||||||
The source preflight refuses a release while the payment and messaging adapters
|
The source preflight reports while the payment and messaging adapters are
|
||||||
are fake. It remains advisory on push checks so development can continue, but a
|
fake. From 2026-09-23 it was enforced on every manual release, and since the
|
||||||
manual release is blocked until those adapters are replaced. Set the repository
|
adapters are still fake no release could succeed: production kept running
|
||||||
variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` when all pushes should also
|
older images while `main` moved on. It is now advisory everywhere. Set the
|
||||||
fail on those blockers. Before a manual release, run the full configuration
|
repository variable `ENFORCE_PRODUCTION_PREFLIGHT` to `true` once the real
|
||||||
|
adapters are in place, and a blocked preflight then stops images from being
|
||||||
|
published. Before a manual release, run the full configuration
|
||||||
preflight below against the actual Portainer values; CI checks source only.
|
preflight below against the actual Portainer values; CI checks source only.
|
||||||
|
|
||||||
CRITICAL image findings block. Both images carry none: the bases are pinned by
|
CRITICAL image findings block. Both images carry none: the bases are pinned by
|
||||||
|
|||||||
@@ -713,8 +713,11 @@ print-file evidence still need correction before this item can close.
|
|||||||
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
|
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
|
||||||
and object backups, a consistent snapshot boundary, Swarm data placement and
|
and object backups, a consistent snapshot boundary, Swarm data placement and
|
||||||
a restore rehearsal that opens every required live order file.
|
a restore rehearsal that opens every required live order file.
|
||||||
- `[ ]` 5.14 — Promote and verify one immutable release. Normal `main` pushes
|
- `[ ]` 5.14 — Promote and verify one immutable release. **2026-09-24:** green
|
||||||
now run checks only; manual dispatch requires source preflight and a configured
|
pushes to `main` now publish images and Portainer's pull-and-redeploy is the
|
||||||
|
release gate; the source preflight is advisory unless enforced by variable,
|
||||||
|
because enforcing it while the adapters are fake made every release fail.
|
||||||
|
Previously: normal `main` pushes ran checks only; manual dispatch required source preflight and a configured
|
||||||
webhook, and scans images before publishing. Still make the full preflight
|
webhook, and scans images before publishing. Still make the full preflight
|
||||||
validate the active stack, deploy the tested immutable image references, test
|
validate the active stack, deploy the tested immutable image references, test
|
||||||
clean install and upgrade, check application readiness after Portainer
|
clean install and upgrade, check application readiness after Portainer
|
||||||
|
|||||||
Reference in New Issue
Block a user