Author SHA1 Message Date
james.maesandCodex f9a1318e6a fix: restore shared live-service e2e validation
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 01:39:57 -05:00
james.maesandCodex 1a6e3bf00e ci: adopt always-reporting shared database validation (#4)
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 22:27:49 -05:00
james.maesandCodex b378500062 ci: delegate business checks to shared QQQ workflows (#3)
* ci: delegate business checks to shared QQQ workflows

Pin canonical database and E2E callers to the verified orb 0.7.6 source; preserve triggers, paths and the existing E2E workflow state.

Co-Authored-By: Codex <noreply@openai.com>

* test: verify the current full platform database schema

Retain exact table and changeset checks for identity, content and newsletter migrations. Isolate container state per test and fail when Docker is unavailable instead of silently skipping the migration gate.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-20 21:02:43 -05:00
8 changed files with 56 additions and 157 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.git
**/target
**/node_modules
**/.next
**/playwright-report
**/test-results
+12 -23
View File
@@ -1,26 +1,15 @@
name: Database migrations
on:
pull_request:
paths:
- "db/**"
- "pom.xml"
- ".github/workflows/db-migrations.yml"
'on':
pull_request: {}
push:
branches: [main]
paths:
- "db/**"
- "pom.xml"
branches:
- main
permissions:
contents: read
jobs:
empty-database-migration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Empty PostgreSQL -> migrate -> validate, demo determinism, forward-only policy
run: mvn -B -pl db verify
validate:
uses: QRun-IO/qqq-orb/.github/workflows/business-db.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
concurrency:
group: business-db-${{ github.workflow }}-${{ github.event.pull_request.number ||
github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+9 -111
View File
@@ -1,119 +1,17 @@
name: Required E2E validation
on:
pull_request:
'on':
pull_request: null
push:
branches: [main]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
branches:
- main
workflow_dispatch: null
permissions:
contents: read
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
e2e:
name: E2E / live services
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CI: "true"
E2E_BASE_URL: http://localhost:3004
E2E_API_URL: http://localhost:18080
E2E_KEYCLOAK_URL: http://localhost:18081
NEXTAUTH_URL: http://localhost:3004
NEXTAUTH_SECRET: ci-only-e2e-secret-not-for-production
KEYCLOAK_ISSUER: http://localhost:18081/realms/qqq-platform
KEYCLOAK_CLIENT_ID: platform-web
KEYCLOAK_CLIENT_SECRET: platform-web-secret
API_BASE_URL_INTERNAL: http://localhost:18080
validate:
uses: QRun-IO/qqq-orb/.github/workflows/business-e2e.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
secrets:
E2E_ALERT_WEBHOOK_URL: ${{ secrets.E2E_ALERT_WEBHOOK_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Install web dependencies and Chromium
working-directory: web
run: |
npm ci
npx playwright install --with-deps chromium
- name: Start actual platform dependencies and API
run: docker compose -f deploy/compose/docker-compose.yml up -d --build postgres migrate keycloak minio minio-setup mailpit qqq-server
- name: Wait for actual services
shell: bash
run: |
set -euo pipefail
wait_for() {
local name="$1" url="$2"
for attempt in {1..90}; do
if curl --fail --silent --show-error "$url" >/dev/null; then
echo "$name is ready"
return 0
fi
sleep 2
done
echo "::error::$name did not become ready at $url"
docker compose -f deploy/compose/docker-compose.yml ps
return 1
}
wait_for Keycloak "$E2E_KEYCLOAK_URL/realms/qqq-platform/.well-known/openid-configuration"
wait_for API "$E2E_API_URL/api/public/v1/health"
- name: Run blocking E2E suite
working-directory: web
run: npm run test:e2e:ci
- name: Publish test summary
if: always()
working-directory: web
run: node scripts/report-e2e-results.mjs
- name: Upload Playwright evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-results-${{ github.run_id }}
path: |
web/playwright-report/
web/test-results/
if-no-files-found: warn
retention-days: 14
- name: Notify configured alert webhook
if: failure() && env.E2E_ALERT_WEBHOOK_URL != ''
run: |
curl --fail-with-body -X POST -H 'Content-Type: application/json' \
--data "{\"text\":\"E2E validation failed for $GITHUB_REPOSITORY ($GITHUB_REF_NAME): $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"}" \
"$E2E_ALERT_WEBHOOK_URL"
- name: Show service logs after failure
if: failure()
run: docker compose -f deploy/compose/docker-compose.yml logs --no-color --tail=300
- name: Stop services
if: always()
run: docker compose -f deploy/compose/docker-compose.yml down -v
deployment-gate:
name: Deployment gate / E2E passed
needs: e2e
runs-on: ubuntu-latest
steps:
- run: echo "Deployment is permitted only because the live-service E2E job passed."
@@ -19,7 +19,10 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
class ExternalPostgresMigrationIT {
private static final Set<String> TABLES = Set.of(
"newsletter_subscription", "newsletter_segment", "newsletter_campaign",
"outbound_email", "email_suppression", "opt_in_token");
"outbound_email", "email_suppression", "opt_in_token",
"user_profile", "local_registration", "cms_site", "cms_asset", "cms_author",
"cms_page", "cms_blog_post", "cms_tag", "cms_category", "cms_navigation",
"cms_redirect", "cms_content_revision", "cms_blog_post_category", "cms_blog_post_tag");
@Test
void emptyDatabaseMigratesValidatesAndSeedsDeterministically() throws Exception {
@@ -38,7 +41,7 @@ class ExternalPostgresMigrationIT {
liquibase.update(new Contexts("baseline,demo"), new LabelExpression());
liquibase.update(new Contexts("baseline,demo"), new LabelExpression());
assertEquals(1, count(connection, "newsletter_subscription"));
assertEquals(3, count(connection, "databasechangelog"));
assertEquals(7, count(connection, "databasechangelog"));
}
}
}
@@ -20,17 +20,20 @@ import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers(disabledWithoutDocker = true)
@Testcontainers
@DisabledIfEnvironmentVariable(named = "QQQ_EXTERNAL_DB_URL", matches = ".+")
class NewsletterMigrationIT {
private static final String CHANGELOG = "db/changelog/db.changelog-master.yaml";
private static final Set<String> NEWSLETTER_TABLES = Set.of(
private static final Set<String> PLATFORM_TABLES = Set.of(
"newsletter_subscription", "newsletter_segment", "newsletter_campaign",
"outbound_email", "email_suppression", "opt_in_token");
"outbound_email", "email_suppression", "opt_in_token",
"user_profile", "local_registration", "cms_site", "cms_asset", "cms_author",
"cms_page", "cms_blog_post", "cms_tag", "cms_category", "cms_navigation",
"cms_redirect", "cms_content_revision", "cms_blog_post_category", "cms_blog_post_tag");
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("qqq")
.withUsername("qqq")
.withPassword("qqq");
@@ -42,10 +45,10 @@ class NewsletterMigrationIT {
liquibase.update(new Contexts("baseline"), new LabelExpression());
liquibase.validate();
assertEquals(NEWSLETTER_TABLES, applicationTables(connection));
assertEquals(PLATFORM_TABLES, applicationTables(connection));
assertEquals(0, count(connection, "newsletter_subscription"));
assertEquals(0, count(connection, "newsletter_campaign"));
assertEquals(2, appliedChangeSets(connection));
assertEquals(6, appliedChangeSets(connection));
}
}
@@ -59,16 +62,16 @@ class NewsletterMigrationIT {
assertEquals(1, count(connection, "newsletter_subscription"));
assertEquals(1, count(connection, "newsletter_segment"));
assertEquals(1, count(connection, "newsletter_campaign"));
assertEquals(3, appliedChangeSets(connection));
assertEquals(7, appliedChangeSets(connection));
liquibase.update(new Contexts("baseline,demo"), new LabelExpression());
assertEquals(1, count(connection, "newsletter_subscription"));
assertEquals(3, appliedChangeSets(connection));
assertEquals(7, appliedChangeSets(connection));
}
}
private static Connection connect() throws Exception {
return DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
private Connection connect() throws Exception {
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
private static Liquibase liquibase(Connection connection) throws Exception {
+5 -5
View File
@@ -87,7 +87,7 @@ services:
# ========== Object Storage ==========
minio:
image: minio/minio:latest
image: cgr.dev/chainguard/minio:latest-dev@sha256:25c69c296421a54961076193a136b1b34d81e2c5c66c6fd596e16ec2d2f668be
container_name: qqq-platform-minio
environment:
MINIO_ROOT_USER: minio_admin
@@ -100,7 +100,7 @@ services:
- "19000:9000" # API
- "19001:9001" # Console
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"]
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:9000/minio/health/live || exit 1"]
interval: 10s
timeout: 5s
retries: 5
@@ -110,7 +110,7 @@ services:
# Initialize MinIO buckets
minio-setup:
image: minio/mc:latest
image: cgr.dev/chainguard/minio-client:latest-dev@sha256:d0bce838369160ea7db20589040bee94bdb3e76dbf7fd10aa3eb755ae17a506b
container_name: qqq-platform-minio-setup
depends_on:
minio:
@@ -121,7 +121,7 @@ services:
mc alias set minio http://minio:9000 minio_admin minio_admin_password &&
mc mb minio/platform-assets --ignore-existing &&
mc mb minio/platform-uploads --ignore-existing &&
mc policy set public minio/platform-assets &&
mc anonymous set download minio/platform-assets &&
echo 'MinIO setup completed!'
"
networks:
@@ -275,4 +275,4 @@ volumes:
networks:
platform-network:
driver: bridge
name: qqq-platform
name: qqq-platform
@@ -2,9 +2,9 @@
spring:
datasource:
url: jdbc:postgresql://postgres:5432/qqq_platform
username: platform_app
password: qqq_platform
url: ${DB_URL:jdbc:postgresql://postgres:5432/qqq_platform}
username: ${DB_USERNAME:platform_app}
password: ${DB_PASSWORD:platform_app_password}
driver-class-name: org.postgresql.Driver
# Email configuration for newsletter QBit
@@ -24,8 +24,8 @@ spring:
oauth2:
resourceserver:
jwt:
issuer-uri: http://keycloak:8081/realms/qqq-platform
jwk-set-uri: http://keycloak:8081/realms/qqq-platform/protocol/openid-connect/certs
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8081/realms/qqq-platform}
jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8081/realms/qqq-platform/protocol/openid-connect/certs}
# Newsletter configuration
newsletter:
+1 -1
View File
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
}
// Call backend API from server-side (no CORS issues)
const backendUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18082'
const backendUrl = process.env.API_BASE_URL_INTERNAL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18080'
const response = await fetch(`${backendUrl}/api/public/v1/newsletter/subscribe`, {
method: 'POST',
headers: {