345 lines
15 KiB
YAML
345 lines
15 KiB
YAML
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
|