Skip to content

Nightly data update #118

Nightly data update

Nightly data update #118

Workflow file for this run

name: Nightly data update
# Pipeline notturna:
# IT-specific:
# fetch_indicepa --include-others (refresh seed da IndicePA)
# enrich_from_aoo_uo (Tier-6 non-PEC, validato is_legit)
# preprocess IT (DNS lookups)
# postprocess (overrides + SMTP banner + scrape gated)
# recover_it_unknowns (fallback IndicePA gated)
# finalize_it_unknowns (S0 Tier-6 + Wikidata + scrape gated + DDG)
# cleanup_invalid_mx_attributions (purga cross-tenant)
# backfill_mx_discovery (provenance taxonomy)
# aggregate_istruzione_per_comune (mappa scuole)
# Common:
# validate + build_frontend + build_public_dataset
# → upload-pages-artifact → deploy (job `deploy`, via artifact)
# → commit best-effort (non-bloccante: NON sul cammino del deploy)
#
# DEPLOY DISACCOPPIATO DAL GIT: il sito si pubblica dall'artifact costruito nel
# run, non da un commit. Un fallimento del commit/push non può più bloccare
# l'aggiornamento del sito. Vedi CLAUDE.md §"CRITICAL: Nightly must never break".
on:
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
contents: write
issues: write
pages: write
id-token: write
concurrency:
group: nightly-update
cancel-in-progress: false
jobs:
update-data:
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@v6
with:
# storia completa: serve al rebase del commit notturno su un main che
# può essere avanzato durante il run (push manuali concorrenti).
fetch-depth: 0
- uses: astral-sh/setup-uv@v7
- run: uv python install 3.13
- run: uv sync
# --- IT seed refresh: rebuild data/municipalities_it.json from IndicePA ---
# --include-others è obbligatorio per non perdere scuole/ministeri/ordini
# (senza flag scrive solo comuni/province/regioni).
- name: Refresh IT seed from IndicePA
run: uv run python3 scripts/fetch_indicepa.py --include-others
- name: Seed invariants (no IT-COM-XXX collisions, all are Comuni)
run: uv run pytest tests/test_seed_invariants.py -v
# --- Tier-6 AOO/UO enrichment: domini non-PEC dai dataset ausiliari ---
# Cache su data/indicepa_raw/. Daily usa cache, weekly --refresh
# manuale se vuoi re-scaricare gli 8MB+12MB.
- name: Enrich IT seed with AOO/UO non-PEC (Tier-6)
continue-on-error: true
run: uv run python3 scripts/enrich_from_aoo_uo.py
# --- SCANSIONI NON-IT DISATTIVATE -------------------------------------
# Il progetto attivo è l'Osservatorio italiano → data.json è IT-only
# (vedi "Strip data.json to Italy only" + scripts/strip_to_it.py). Le
# scansioni del fork mondiale restano nel repo ma disabilitate (if: false):
# rimuovi gli `if: false` per riattivare il multi-paese.
- name: Preprocess (small + medium countries)
if: false
run: |
# Countries with <1000 municipalities — scan every night
SMALL=$(uv run python3 -c "
import json
from mail_sovereignty.preprocess import SEED_FILES
from pathlib import Path
large = set()
for cc, fname in SEED_FILES.items():
p = Path('data') / fname
if p.exists():
n = len(json.load(open(p)))
if n >= 1000:
large.add(cc)
print(' '.join(cc for cc in SEED_FILES if cc not in large and cc != 'DE'))
")
echo "Small/medium countries: $SMALL"
uv run preprocess $SMALL
- name: Preprocess large countries (rotating)
if: false
run: |
# Countries with >=1000 municipalities rotate on a 3-day cycle.
# Esclusi: DE (rotazione 3-statestate-per-night sotto) e IT
# (preprocess dedicato daily, vedi step successivo).
DAY_OF_YEAR=$(date +%-j)
LARGE=$(uv run python3 -c "
import json
from mail_sovereignty.preprocess import SEED_FILES
from pathlib import Path
large = []
for cc, fname in sorted(SEED_FILES.items()):
if cc in ('DE', 'IT'): continue
p = Path('data') / fname
if p.exists():
n = len(json.load(open(p)))
if n >= 1000:
large.append(cc)
day = $DAY_OF_YEAR
group_size = (len(large) + 2) // 3
idx = day % 3
batch = large[idx * group_size:(idx + 1) * group_size]
print(' '.join(batch))
")
echo "Large country batch: $LARGE"
if [ -n "$LARGE" ]; then
uv run preprocess $LARGE
fi
# --- IT preprocess (daily, full) ---
# 22,935 enti, DNS cache caldo → ~5-8 min, freddo ~30 min.
# Sostituisce la rotazione perchè IT è il focus primario del progetto.
- name: Preprocess IT (daily, full)
run: uv run preprocess IT
# data.json IT-only: rimuove eventuali enti non-IT residui + ricalcola counts.
- name: Strip data.json to Italy only
run: uv run python3 scripts/strip_to_it.py --keep IT
- name: Preprocess DE (rotating 3 states per night)
if: false
run: |
DAY=$(date +%j)
IDX=$(( DAY % 6 ))
GROUPS=("01,02,03" "04,05,06" "07,08,09" "10,11,12" "13,14,15" "16")
IFS=',' read -ra STATES <<< "${GROUPS[$IDX]}"
echo "Day $DAY → group $IDX: states ${STATES[*]}"
for S in "${STATES[@]}"; do
uv run preprocess "DE:$S"
done
- run: uv run postprocess
# --- IT recovery chain: applica solo agli enti IT con provider=unknown ---
# Ordine importante: recover (fallback IndicePA) → finalize (Tier-6 +
# Wikidata + scrape + DDG) → cleanup (purga cross-tenant) → backfill
# (provenance taxonomy) → aggregate (mappa scuole).
- name: Recover IT unknowns (domain_fallbacks gated by is_legit)
run: uv run python3 scripts/recover_it_unknowns.py
- name: Finalize IT unknowns (Tier-6 S0 + Wikidata + scrape + DDG)
continue-on-error: true
run: uv run python3 scripts/finalize_it_unknowns.py
- name: Cleanup invalid MX attributions (cross-tenant)
run: uv run python3 scripts/cleanup_invalid_mx_attributions.py
- name: Backfill mx_discovery_method taxonomy
run: uv run python3 scripts/backfill_mx_discovery.py
- name: Aggregate istruzione per comune (mappa scuole)
continue-on-error: true
run: uv run python3 scripts/aggregate_istruzione_per_comune.py
# Arricchimento geografico strutturale: regione/provincia/comune da ISTAT
# (chiave-sede ipa_codice_comune_istat). Sblocca l'asse "per aree".
- name: Enrich geo (regione/provincia da crosswalk ISTAT)
run: uv run python3 scripts/enrich_geo.py --country IT
# Confidence ESORICS + sovranità DOMESTIC/FOREIGN + domestic-MX-override.
# Dopo recovery/cleanup (provider finale), prima di build_frontend
# (così i campi confidence/jurisdiction finiscono in data-detail.json).
- name: Confidence scoring + sovereignty (ESORICS)
run: uv run python3 scripts/compute_confidence.py --country IT
- name: Confidence report (1 pagina, anticipazione bounce)
continue-on-error: true
run: uv run python3 scripts/report_confidence.py --country IT
- name: Anomalies report (pagina pubblica anomalie.html)
continue-on-error: true
run: uv run python3 scripts/report_anomalies.py --country IT
# Quality gate = SEGNALE, non blocco del deploy. Lo score euristico
# 0-100 di validate.py è tarato sul fork Baltic; il dataset IT
# (con unknown onesti per integrità) sta legittimamente ~80%. Soglia
# high-confidence calata a 75 (floor realistico). Il fallimento del
# gate crea un issue di visibilità ma NON blocca build/deploy: il sito
# deve sempre servire il dato corretto più recente, non restare fermo
# su dati vecchi per un'euristica legacy.
- name: Validate data quality (signal only)
id: validate
continue-on-error: true
env:
MIN_HIGH_CONFIDENCE_PCT: "75"
run: uv run validate
- name: Build frontend data
run: uv run python3 scripts/build_frontend.py
- name: Build public dataset (CSV/JSON/XLSX)
run: uv run python3 scripts/build_public_dataset.py
# KPI correnti per statistiche.html — NON-gated (fotografia di oggi, solo
# aggregato di data.json). Le serie storiche restano gated (historicize sotto).
- name: Build current KPIs (pagina Statistiche)
run: uv run python3 scripts/build_stats.py --country IT
# KPI aggregati pubblici (kpi.json) per l'Osservatorio Sovranità Digitale.
- name: Build public KPI feed (Osservatorio)
run: uv run python3 scripts/build_kpi.py --country IT
# Report pubblico (report.json) scaricato/pescato dall'Osservatorio.
- name: Build public report (Osservatorio)
run: uv run python3 scripts/build_report.py --country IT
# Pagine SEO per-ente + hub geografici + categorie + alias dominio +
# sitemap INDEX (#15). Genera ~48k file statici (una pagina per ente con
# tutti i dati di rilevamento, hub regione/provincia/comune, facet per
# categoria) sotto /ente /aree /categoria /dominio, e i sitemap (index +
# figli). I file finiscono SOLO nell'artifact Pages (git-ignored). È il
# generatore unico dei sitemap: <lastmod> da kpi.json:generated_at, quindi
# rigenerato a ogni run e "manutenuto nel tempo". DOPO build_kpi.
# --no-domain-aliases: NON generare le ~22k pagine /dominio/ (reindirizzamenti
# canonical→ente, NON nel sitemap, valore SEO ~nullo). Dimezza il numero di
# file (~53k→~31k) e rende affidabile il sync di GitHub Pages, che a 53k file
# falliva a intermittenza in "syncing_files" (2026-07-03). Reversibile.
- name: Build per-entity & area SEO pages + sitemap index (#15)
run: uv run python3 scripts/build_entity_pages.py --country IT --no-domain-aliases
# --- Storicizzazione (F1) — DISATTIVATA finché non risolviamo le anomalie.
# Il run #1 ufficiale dello storico dev'essere il primo scan su dati "da
# rollout" (anomalie risolte), non un run sporco. Per attivarla: togliere
# il commento sotto (history/ è già incluso nel git-add tollerante).
# - name: Historicize run (snapshot + diff + changelog + timeseries + entity)
# run: |
# uv run python3 scripts/historicize.py --run-id "$(date -u +%F)" \
# --git-sha "${{ github.sha }}" --pipeline-version "0.1.0"
# - name: DCAT-AP_IT metadata (harvesting dati.gov.it)
# run: uv run python3 scripts/build_dcat.py
# --- PUBBLICAZIONE DEL SITO (disaccoppiata dal commit git) ---------------
# Carica i dati appena costruiti come artifact Pages. Il job `deploy`
# (needs: update-data) pubblica QUESTO artifact. Sta PRIMA del commit git:
# così, qualunque cosa succeda al commit/push (che storicamente si rompeva),
# il sito si aggiorna comunque. È il pilastro del fix definitivo.
- name: Upload Pages artifact (sito + dati freschi)
uses: actions/upload-pages-artifact@v4
with:
path: .
# --- COMMIT DEI DATI: BEST-EFFORT, NON BLOCCANTE -------------------------
# `continue-on-error: true` ⇒ un fallimento qui NON rompe più il job né
# blocca il deploy (i dati sono già nell'artifact sopra). Conserviamo lo
# storico git dei dati (scelta di progetto), ma non è più sul cammino
# critico della pubblicazione. L'esito viene catturato (id: commit) per
# aprire un issue di rilevamento automatico se fallisce.
- name: Commit and push data (best-effort, non-blocking)
id: commit
continue-on-error: true
run: |
git diff --quiet data.json && { echo "data.json invariato: niente da committare"; exit 0; }
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Stage SOLO i path esistenti: i report opzionali (step continue-on-error)
# possono mancare, e un `git add` su un path inesistente farebbe fallire
# tutto lo step.
for p in data.json data-regions.json data-summary.json data-detail.json kpi.json report.json \
data/summary dist validation_report.json validation_report.csv data/dns_cache \
data/municipalities_it.json data/indicepa_extended_emails.json data/it_istruzione_by_comune.json \
data/reports/cleanup_invalid_mx_attributions.json data/reports/recover_it_unknowns_rejections.json \
data/reports/mx_discovery_stats.json data/reports/confidence_report.json data/reports/confidence_report.md \
data/reports/anomalies.json data/reports/anomalies.md history; do
[ -e "$p" ] && git add "$p"
done
git commit -m "chore: nightly data update" || { echo "niente di staged: esco pulito"; exit 0; }
# Pubblica sul tip di main ANCHE se main è avanzato durante il run:
# fetch+rebase+push con retry. In caso di conflitto vince la versione
# appena costruita (`rebase -X theirs`), i dati essendo deterministici.
# --autostash: la pipeline lascia il working tree sporco (file tracciati
# NON nella lista del commit — es. uv.lock toccato da `uv sync`, cache).
# Senza autostash il rebase moriva con "cannot rebase: You have unstaged
# changes" a OGNI run (#10). autostash le mette da parte e le riapplica,
# senza committarle.
# Catturiamo stderr di ogni git per documentare la CAUSA REALE su #10
# (vedi auto-issue sotto), così ogni fallimento è auto-documentato.
DIAG="(nessun dettaglio catturato)"
for attempt in 1 2 3 4 5; do
if ! out=$(git fetch origin main 2>&1); then
DIAG="fetch fallito (tentativo $attempt):"$'\n'"$out"; echo "$DIAG"; sleep 5; continue
fi
if ! out=$(git rebase --autostash -X theirs origin/main 2>&1); then
DIAG="rebase fallito (tentativo $attempt):"$'\n'"$out"; echo "$DIAG"
git rebase --abort || true; sleep 5; continue
fi
if out=$(git push origin HEAD:main 2>&1); then
echo "push ok al tentativo $attempt"; exit 0
fi
DIAG="push rifiutato (tentativo $attempt):"$'\n'"$out"; echo "$DIAG"; sleep 5
done
# Tutti i tentativi falliti: il deploy NON è impattato (artifact). Segnaliamo
# (warning + outcome=failure) ed ESPORTIAMO una diagnostica (errore git +
# stato del working tree) così l'auto-issue documenta SEMPRE la causa su #10.
echo "::warning::commit/push notturno fallito dopo 5 tentativi (deploy NON impattato)"
{
echo "diag<<__DIAG_EOF__"
echo "**Ultimo errore git:**"
printf '```\n%s\n```\n' "$(echo "$DIAG" | head -20)"
echo "**Working tree al fallimento (\`git status --short\`):**"
printf '```\n%s\n```\n' "$(git status --short | head -30)"
echo "__DIAG_EOF__"
} >> "$GITHUB_OUTPUT"
exit 1
- name: Create issue on quality gate failure
if: steps.validate.outcome == 'failure'
continue-on-error: true
run: |
existing=$(gh issue list --label quality-gate --state open --limit 1 --json number -q '.[0].number')
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "Quality gate failed again on $(date -u +%Y-%m-%d). See [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
else
gh issue create \
--title "Quality gate failure: nightly data update" \
--label quality-gate \
--body "The nightly data update failed the quality gate on $(date -u +%Y-%m-%d).
**Action required:** Review the validation report and fix upstream data issues.
See [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details."
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# --- AUTO-RILEVAMENTO 1/2: commit dei dati fallito -----------------------
# Il commit è best-effort (continue-on-error) e NON rompe il job, ma se
# fallisce vogliamo accorgercene: apre/aggiorna un issue. Il sito è già
# stato aggiornato dall'artifact, quindi l'issue è informativo, non urgente.
- name: Open issue if data commit failed (auto-detection)
if: steps.commit.outcome == 'failure'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DIAG: ${{ steps.commit.outputs.diag }}
run: |
gh label create nightly-commit --color FBCA04 --description "Commit notturno dei dati fallito (deploy non impattato)" 2>/dev/null || true
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
existing=$(gh issue list --label nightly-commit --state open --limit 1 --json number -q '.[0].number')
# Corpo via file: include la DIAGNOSTICA reale ($DIAG da env, anti-injection)
# + la causa nota e la soluzione → #10 si auto-documenta a OGNI fallimento.
body=$(mktemp)
{
echo "Commit/push dei dati notturno **fallito** il $(date -u +%Y-%m-%d) dopo 5 retry. Il sito è stato comunque aggiornato (deploy via artifact Pages, indipendente dal git). [Run]($run_url)."
echo ""
echo "### Diagnostica del fallimento"
echo "${DIAG:-(diagnostica non disponibile)}"
echo ""
echo "### Causa nota e soluzione"
echo "Causa storica: \`git rebase\` si rifiutava su working tree sporco — *\"cannot rebase: You have unstaged changes\"* — perché la pipeline lascia file tracciati fuori dalla lista del commit (es. \`uv.lock\` da \`uv sync\`). **Soluzione applicata:** \`git rebase --autostash\` (commit \`824bbc57\`)."
echo ""
echo "Se la diagnostica qui sopra mostra un errore **diverso** (es. push rifiutato per branch protection, o un conflitto di rebase reale), è un caso nuovo da indagare separatamente."
} > "$body"
if [ -n "$existing" ]; then
gh issue comment "$existing" --body-file "$body"
else
gh issue create --title "Nightly: commit dei dati fallito (sito comunque aggiornato)" --label nightly-commit --body-file "$body"
fi
# --- AUTO-RILEVAMENTO 2/2: hard-failure di uno step della pipeline --------
# Catch-all: scatta solo se uno step NON best-effort è fallito (build,
# preprocess, ecc.) — quindi NON per il commit (continue-on-error sopra).
# Deve restare l'ULTIMO step del job per "vedere" i fallimenti precedenti.
- name: Report nightly hard failure (auto-detection)
if: failure()
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh label create nightly-failure --color B60205 --description "Step della nightly fallito (build/pipeline)" 2>/dev/null || true
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
existing=$(gh issue list --label nightly-failure --state open --limit 1 --json number -q '.[0].number')
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "La nightly è di nuovo fallita su uno step hard il $(date -u +%Y-%m-%d). [Run]($run_url)."
else
gh issue create \
--title "Nightly: fallimento di uno step della pipeline" \
--label nightly-failure \
--body "Uno step NON best-effort della nightly è fallito il $(date -u +%Y-%m-%d) (build/pipeline, non il commit).
Cause tipiche: (a) una modifica di codice che rompe uno script — dovrebbe già essere stata intercettata dal job CI 'smoke' su push (vedi ci.yml); (b) errore transitorio di rete (IndicePA/DNS/SMTP), che di norma si risolve da solo al run successivo.
**Impatto sul sito:** se il fallimento è prima della build, il sito resta all'ultimo deploy valido (dati non aggiornati, NON rotti). [Run]($run_url)."
fi
deploy:
needs: update-data
runs-on: ubuntu-latest
concurrency:
group: pages
cancel-in-progress: true
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url || steps.deploy_try2.outputs.page_url || steps.deploy_try1.outputs.page_url }}
steps:
# DEPLOY DISACCOPPIATO DAL GIT. Pubblichiamo l'artifact Pages costruito
# DENTRO il job update-data (step "Upload Pages artifact"), NON un
# checkout di main. Motivo: il vecchio design faceva `checkout main` e
# serviva i dati solo se il commit/push notturno era riuscito — e quel
# commit si è rotto due volte (push race / rebase), bloccando ogni
# aggiornamento del sito. Ora il sito si aggiorna SEMPRE coi dati appena
# costruiti nel run, indipendentemente dall'esito del commit git.
# Regola di progetto: CLAUDE.md §"CRITICAL: Nightly must never break".
#
# RETRY del deploy: actions/deploy-pages ri-sincronizza TUTTI i ~31k file a
# ogni deploy (full-snapshot, nessun incrementale) e può fallire con
# "Deployment failed, try again later" — transiente infra RETRIABLE (fase
# "syncing_files"). 3 tentativi con backoff (30s, 90s): la nightly unattended
# (07:00) si auto-guarisce anche nelle finestre in cui Pages è degradato,
# invece di richiedere un re-run manuale.
- id: deploy_try1
uses: actions/deploy-pages@v4
continue-on-error: true
- if: steps.deploy_try1.outcome == 'failure'
run: 'echo "deploy 1/3 fallito (transiente): retry tra 30s..."; sleep 30'
- id: deploy_try2
if: steps.deploy_try1.outcome == 'failure'
uses: actions/deploy-pages@v4
continue-on-error: true
- if: steps.deploy_try1.outcome == 'failure' && steps.deploy_try2.outcome == 'failure'
run: 'echo "deploy 2/3 fallito (transiente): retry tra 90s..."; sleep 90'
- id: deployment
if: steps.deploy_try1.outcome == 'failure' && steps.deploy_try2.outcome == 'failure'
uses: actions/deploy-pages@v4