krz/omaha-metro-blotter

Archive of police activity and ALPR surveillance across the Omaha metro.

clone: git clone https://gitbay.org/krz/omaha-metro-blotter.git

archive: .github/workflows/daily-pull.yml · raw

  1# Sarpy and Council Bluffs serve a rolling 12-month window; records that age out
  2# of those feeds exist nowhere else. This job is the only thing keeping them, so
  3# it refuses to publish an archive smaller than the one it started with.
  4name: daily pull
  5
  6on:
  7  schedule:
  8    # 06:00 America/Chicago in summer, 05:00 in winter. Offset from the hour
  9    # because GitHub drops on-the-hour scheduled runs under load.
 10    - cron: "17 11 * * *"
 11  workflow_dispatch:
 12    inputs:
 13      bootstrap:
 14        description: "Start a new archive instead of restoring the published one"
 15        type: boolean
 16        default: false
 17      full:
 18        description: "Pull each feed in full rather than the last 30 days"
 19        type: boolean
 20        default: false
 21
 22permissions:
 23  contents: write
 24
 25concurrency:
 26  group: archive
 27  cancel-in-progress: false
 28
 29env:
 30  TAG: archive
 31  DB: raw_data/metro.db
 32  GH_TOKEN: ${{ github.token }}
 33
 34jobs:
 35  pull:
 36    runs-on: ubuntu-latest
 37    timeout-minutes: 30
 38
 39    steps:
 40      - uses: actions/checkout@v4
 41
 42      - uses: actions/setup-python@v5
 43        with:
 44          python-version: "3.13"
 45          cache: pip
 46          cache-dependency-path: requirements-ingest.txt
 47
 48      - run: pip install -r requirements-ingest.txt
 49
 50      - name: Restore archive
 51        run: |
 52          if gh release download "$TAG" --pattern metro.db.gz --dir .; then
 53            gunzip -c metro.db.gz > "$DB"
 54            rm metro.db.gz
 55            echo "restored $(du -h "$DB" | cut -f1)"
 56          elif [ "${{ inputs.bootstrap }}" = "true" ]; then
 57            echo "no published archive; starting a new one"
 58          else
 59            echo "::error::No metro.db.gz on release '$TAG'. Anything that has" \
 60                 "already aged out of the Sarpy and Council Bluffs feeds cannot" \
 61                 "be recovered. Re-run with bootstrap only if that is intended."
 62            exit 1
 63          fi
 64
 65      - name: Count rows before
 66        run: |
 67          : > before.txt
 68          if [ -f "$DB" ]; then
 69            sqlite3 -noheader -separator ' ' "$DB" \
 70              "SELECT source, COUNT(*) FROM incidents GROUP BY source ORDER BY source" \
 71              > before.txt
 72          fi
 73          cat before.txt
 74
 75      - name: Pull feeds
 76        run: |
 77          if [ "${{ inputs.full }}" = "true" ] || [ "${{ inputs.bootstrap }}" = "true" ]; then
 78            python ingest.py --full
 79          else
 80            python ingest.py
 81          fi
 82
 83      - name: Check nothing was lost
 84        run: |
 85          sqlite3 -noheader -separator ' ' "$DB" \
 86            "SELECT source, COUNT(*) FROM incidents GROUP BY source ORDER BY source" \
 87            > after.txt
 88          cat after.txt
 89          test -s after.txt || { echo "::error::archive is empty"; exit 1; }
 90          # Keyed on FILENAME, not NR == FNR: before.txt is empty on a bootstrap
 91          # run, and awk never resets FNR for a zero-length file.
 92          awk -v first=before.txt '
 93               FILENAME == first { was[$1] = $2; next }
 94               { now[$1] = $2 }
 95               END {
 96                 for (s in was)
 97                   if (now[s] + 0 < was[s] + 0) {
 98                     printf "::error::%s lost rows: %d -> %d\n", s, was[s], now[s]
 99                     bad = 1
100                   }
101                 exit bad
102               }' before.txt after.txt
103
104      - name: Publish archive
105        run: |
106          sqlite3 "$DB" "VACUUM;"
107          gzip -c "$DB" > metro.db.gz
108          gh release view "$TAG" >/dev/null 2>&1 \
109            || gh release create "$TAG" --title "Incident archive" --notes "building"
110          gh release upload "$TAG" metro.db.gz --clobber
111          {
112            echo "SQLite archive of Omaha metro police incident feeds, rebuilt daily."
113            echo "Sarpy County and Council Bluffs publish a rolling 12-month window,"
114            echo "so this holds records their own feeds no longer serve."
115            echo
116            echo "Updated $(date -u '+%Y-%m-%d %H:%M UTC'). Schema: schema.sql."
117            echo
118            echo '```'
119            sqlite3 -header -column "$DB" \
120              "SELECT agency, COUNT(*) AS rows, SUM(is_stop) AS stops,
121                      MIN(occurred_at) AS earliest, MAX(occurred_at) AS latest
122               FROM incidents GROUP BY agency ORDER BY rows DESC"
123            echo '```'
124          } > notes.md
125          gh release edit "$TAG" --notes-file notes.md
126
127      # Runs after the upload on purpose: a feed that stopped updating should
128      # raise the alarm without also blocking the archive from being published.
129      - name: Check the feeds are still moving
130        run: |
131          sqlite3 -noheader "$DB" \
132            "SELECT source || ' ' || MAX(occurred_at) FROM incidents
133             WHERE source <> 'opd_csv' GROUP BY source
134             HAVING MAX(occurred_at) < datetime('now', '-7 days')" > stale.txt
135          if [ -s stale.txt ]; then
136            while read -r line; do echo "::error::feed is stale: $line"; done < stale.txt
137            exit 1
138          fi
139          echo "all feeds current"
140
141      - name: Summary
142        if: always()
143        run: |
144          {
145            echo "| source | before | after |"
146            echo "|---|---|---|"
147            awk -v first=before.txt '
148                 FILENAME == first { was[$1] = $2; next }
149                 { printf "| %s | %s | %s |\n", $1, ($1 in was ? was[$1] : 0), $2 }' \
150                before.txt after.txt
151          } >> "$GITHUB_STEP_SUMMARY"