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 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 # The historical production reset is a one-shot operational migration, # not a bootstrap migration: it requires production data/configuration # that cannot exist at its timestamp in a database rebuilt from zero. # Prove the clean chain reaches that exact guard, then mark only that # one-shot migration as already applied and continue the reproducible # schema chain. The historical migration itself remains untouched. 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 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; real_inventory integer; source_areas integer; source_yacimientos integer; source_installations integer; source_subinstallations integer; source_findings integer; BEGIN SELECT COUNT(*) INTO f5_migrations FROM typeorm_migrations WHERE name IN ( 'F5InventoryPhysicalInstance1790087100000', 'F5AuthoritativeTerritory1790087200000', 'F5OperationalContextCompatibility1790087250000', 'F5AuthoritativeInventoryCatalog1790087300000' ); IF f5_migrations <> 4 THEN RAISE EXCEPTION 'Expected 4 F5 migrations, got %', f5_migrations; END IF; SELECT COUNT(*) INTO real_inventory FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE'; IF real_inventory <> 0 THEN RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory; END IF; SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'), COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento') INTO source_areas,source_yacimientos FROM source_documents document JOIN asset_source_documents link ON link.document_id=document.id JOIN assets asset ON asset.id=link.asset_id JOIN asset_types type ON type.id=asset.asset_type_id WHERE document.document_number='DH-F5-TERRITORY'; IF source_areas <> 64 OR source_yacimientos <> 230 THEN RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos; 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 'F5:final_modelov2.xlsx%'; IF source_installations <> 14 OR source_subinstallations <> 109 THEN RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', 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)='f5model' AND item.is_active=true; IF source_findings <> 177 THEN RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings; END IF; END $$; SQL # Prove the four F5 migrations are actually reversible on a clean state. for _ in 1 2 3 4; do docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert done 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; instance_column integer; BEGIN SELECT COUNT(*) INTO f5_migrations FROM typeorm_migrations WHERE name LIKE 'F5%1790087%'; IF f5_migrations <> 0 THEN RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations; END IF; SELECT COUNT(*) INTO instance_column FROM information_schema.columns WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance'; IF instance_column <> 0 THEN RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind'; END IF; END $$; SQL # Reapply them once more. Each F5 migration performs its own source/cardinality checks. docker compose --env-file .env.example --profile tools run --rm migrate docker compose --env-file .env.example exec -T db \ psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \ "SELECT CASE WHEN COUNT(*)=4 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \ | grep -Fx 'F5_REAPPLY_OK' - name: VPS-equivalent isolated API 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