name: DH V2 CI on: pull_request: branches: [main] push: branches: [main] permissions: contents: read concurrency: group: dhv2-ci-${{ github.ref }} cancel-in-progress: true jobs: api: name: API · typecheck, tests, build runs-on: ubuntu-latest defaults: run: working-directory: api-v3 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '24' cache: npm cache-dependency-path: api-v3/package-lock.json - run: npm ci - run: npm run typecheck - run: npm test - run: npm run build web: name: WEB · typecheck, build runs-on: ubuntu-latest defaults: run: working-directory: web-v2 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '24' cache: npm cache-dependency-path: web-v2/package-lock.json - run: npm ci - run: npm run typecheck - name: F3.1 WEB contract run: bash ../scripts/check-f3-1-web-contract.sh - run: npm run build contract: name: Docker / scripts contract runs-on: ubuntu-latest needs: [api, web] steps: - uses: actions/checkout@v4 - name: Validate shell scripts run: | while IFS= read -r -d '' script; do bash -n "$script" done < <(find scripts -type f -name '*.sh' -print0) - name: Validate deploy preflight parity run: | grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh - name: Validate Compose run: docker compose --env-file .env.example config >/dev/null - name: Rehearse migrations and real API startup on clean PostGIS run: | set -Eeuo pipefail cleanup() { docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT cleanup docker compose --env-file .env.example up -d db # Historical production reset is a one-shot migration that expects the # production admin. Prove the clean chain reaches that exact guard, # mark only that historical reset as applied, then continue the chain. bootstrap_log="$(mktemp)" set +e docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log" bootstrap_status=${PIPESTATUS[0]} set -e if [ "$bootstrap_status" -eq 0 ]; then echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2 exit 1 fi grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || { echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2 exit 1 } rm -f "$bootstrap_log" docker compose --env-file .env.example exec -T db \ psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' DO $$ DECLARE reset_rows integer; BEGIN SELECT COUNT(*) INTO reset_rows FROM typeorm_migrations WHERE name = 'ResetProductionOperationalData1788652800000'; IF reset_rows <> 0 THEN RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows; END IF; INSERT INTO typeorm_migrations ("timestamp", name) VALUES (1788652800000, 'ResetProductionOperationalData1788652800000'); END $$; SQL docker compose --env-file .env.example --profile tools run --rm migrate # The completed chain must end in the exact authoritative SQL model: # 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de # concesión, 14/109 technical families, 181 Hallazgos and their exact # 48 + 880 contextual mappings. docker compose --env-file .env.example exec -T db \ psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' DO $$ DECLARE f5_migrations integer; domain_assets integer; audits integer; applicability integer; legacy_territory_sources integer; legacy_presentation_sources integer; authoritative_sources integer; source_installations integer; source_subinstallations integer; source_findings integer; concession_types integer; departments integer; areas integer; yacimientos integer; companies integer; invalid_yacimientos integer; department_types integer; area_department_rules integer; yacimiento_area_rules integer; installation_yacimiento_rules integer; subinstallation_installation_rules integer; BEGIN SELECT COUNT(*) INTO f5_migrations FROM typeorm_migrations WHERE name IN ( 'F5InventoryPhysicalInstance1790087100000', 'F5CanonicalInventoryHierarchy1790087150000', 'F5AuthoritativeTerritory1790087200000', 'F5OperationalContextCompatibility1790087250000', 'F5AuthoritativeInventoryCatalog1790087300000', 'F51CleanManualInventory1790087400000' ); IF f5_migrations <> 6 THEN RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations; END IF; SELECT COUNT(*) INTO domain_assets FROM assets; IF domain_assets <> 314 THEN RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets; END IF; SELECT COUNT(*) INTO audits FROM audit_events; IF audits <> 0 THEN RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits; END IF; SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families; IF applicability <> 928 THEN RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability; END IF; SELECT COUNT(*) INTO legacy_territory_sources FROM source_documents WHERE document_number='DH-F5-TERRITORY'; IF legacy_territory_sources <> 0 THEN RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources; END IF; SELECT COUNT(*) INTO legacy_presentation_sources FROM source_documents WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909'; IF legacy_presentation_sources <> 0 THEN RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources; END IF; SELECT COUNT(*) INTO authoritative_sources FROM source_documents WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911'); IF authoritative_sources <> 2 THEN RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_sources; END IF; SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'), COUNT(*) FILTER (WHERE level='SUBINSTALLATION') INTO source_installations,source_subinstallations FROM inventory_families WHERE is_active=true AND source_reference LIKE 'modelo_relacional:%'; IF source_installations <> 14 OR source_subinstallations <> 109 THEN RAISE EXCEPTION 'Authoritative technical families must be 14 installations and 109 subinstallations, got % and %', source_installations,source_subinstallations; END IF; SELECT COUNT(*) INTO source_findings FROM finding_catalog_items item JOIN finding_categories category ON category.id=item.category_id WHERE lower(category.code)='authmodel' AND item.is_active=true; IF source_findings <> 181 THEN RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings; END IF; SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true; IF concession_types <> 2 THEN RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types; END IF; SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'), COUNT(*) FILTER (WHERE lower(type.code)='area'), COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'), COUNT(*) FILTER (WHERE type.operational_role='COMPANY') INTO departments,areas,yacimientos,companies FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id; IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies; END IF; SELECT COUNT(*) INTO invalid_yacimientos FROM assets yacimiento JOIN asset_types type ON type.id=yacimiento.asset_type_id JOIN assets area ON area.id=yacimiento.parent_id WHERE lower(type.code)='yacimiento' AND ( yacimiento.operational_area_id IS DISTINCT FROM area.id OR yacimiento.operator_company_id IS NULL OR yacimiento.concession_type_id IS NULL ); IF invalid_yacimientos <> 0 THEN RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos; END IF; SELECT COUNT(*) INTO department_types FROM asset_types WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true; IF department_types <> 1 THEN RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types; END IF; SELECT COUNT(*) INTO area_department_rules FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id WHERE lower(child.code)='area' AND lower(parent.code)='departamento'; IF area_department_rules <> 1 THEN RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules; END IF; SELECT COUNT(*) INTO yacimiento_area_rules FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id WHERE lower(child.code)='yacimiento' AND lower(parent.code)='area'; IF yacimiento_area_rules <> 1 THEN RAISE EXCEPTION 'Expected Yacimiento → Área canonical rule, got %', yacimiento_area_rules; END IF; SELECT COUNT(*) INTO installation_yacimiento_rules FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id WHERE lower(child.code)='instalacion' AND lower(parent.code)='yacimiento'; IF installation_yacimiento_rules <> 1 THEN RAISE EXCEPTION 'Expected Instalación → Yacimiento canonical rule, got %', installation_yacimiento_rules; END IF; SELECT COUNT(*) INTO subinstallation_installation_rules FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id WHERE lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion'; IF subinstallation_installation_rules <> 1 THEN RAISE EXCEPTION 'Expected Subinstalación → Instalación canonical rule, got %', subinstallation_installation_rules; END IF; END $$; SQL # Destructive cuts are intentionally one-way: production rollback is # the PRE database backup, not migration:revert. Prove the completed # chain is idempotent and has no pending migration on a second run. rerun_log="$(mktemp)" docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log" grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || { echo "ERROR: authoritative migration chain is not idempotent." >&2 cat "$rerun_log" >&2 exit 1 } rm -f "$rerun_log" # A build-only preflight cannot catch Nest dependency-injection or # runtime configuration failures. Start the production API image against # the migrated database and require the public health endpoint to answer. export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' export SMTP_SETTINGS_MASTER_KEY='' docker compose --env-file .env.example build api docker compose --env-file .env.example up -d api api_ready=0 for _ in $(seq 1 30); do if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then api_ready=1 break fi sleep 2 done if [ "$api_ready" -ne 1 ]; then echo 'ERROR: production API image did not become healthy.' >&2 docker compose --env-file .env.example logs --no-color api >&2 || true exit 1 fi grep -Fq '"status":"ok"' /tmp/dhv2-health.json || { echo 'ERROR: /api/v3/health did not report status ok.' >&2 cat /tmp/dhv2-health.json >&2 exit 1 } - name: Isolated builder test preflight run: | set -Eeuo pipefail image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}" docker build --target builder -t "$image" api-v3 docker run --rm \ -v "$PWD/api-v3/test:/app/test:ro" \ -v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ -v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \ -v "$PWD/web-v2:/web-v2:ro" \ -v "$PWD/android-app:/android-app:ro" \ "$image" npm test docker image rm "$image" >/dev/null 2>&1 || true - name: Build production images run: docker compose --env-file .env.example build api migrate web