Repository navigation
Nightly data update #118
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |