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
flock-search-audit: .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 # Twice a day, because the cadence sets how much the rolling feeds drop
9 # before it is captured: a five-hour gap cost 13 Sarpy records once.
10 # Offset from the hour, GitHub drops on-the-hour runs under load.
11 - cron: "17 11 * * *"
12 - cron: "17 23 * * *"
13 workflow_dispatch:
14 inputs:
15 bootstrap:
16 description: "Start a new archive instead of restoring the published one"
17 type: boolean
18 default: false
19 full:
20 description: "Pull each feed in full rather than the last 30 days"
21 type: boolean
22 default: false
23
24permissions:
25 contents: write
26
27concurrency:
28 group: archive
29 cancel-in-progress: false
30
31env:
32 TAG: archive
33 DB: raw_data/metro.db
34 GH_TOKEN: ${{ github.token }}
35
36jobs:
37 pull:
38 runs-on: ubuntu-latest
39 timeout-minutes: 45
40
41 steps:
42 - uses: actions/checkout@v4
43
44 - uses: actions/setup-python@v5
45 with:
46 python-version: "3.13"
47 cache: pip
48 cache-dependency-path: requirements-ingest.txt
49
50 - run: pip install -r requirements-ingest.txt
51
52 - name: Restore archive
53 run: |
54 if gh release download "$TAG" --pattern metro.db.gz --dir .; then
55 gunzip -c metro.db.gz > "$DB"
56 rm metro.db.gz
57 echo "restored $(du -h "$DB" | cut -f1)"
58 elif [ "${{ inputs.bootstrap }}" = "true" ]; then
59 echo "no published archive; starting a new one"
60 else
61 echo "::error::No metro.db.gz on release '$TAG'. Anything that has" \
62 "already aged out of the Sarpy and Council Bluffs feeds cannot" \
63 "be recovered. Re-run with bootstrap only if that is intended."
64 exit 1
65 fi
66
67 - name: Count rows before
68 run: |
69 : > before.txt
70 if [ -f "$DB" ]; then
71 sqlite3 -noheader -separator ' ' "$DB" \
72 "SELECT source, COUNT(*) FROM incidents GROUP BY source ORDER BY source" \
73 > before.txt
74 # absent until the amendment migration has run against this archive
75 sqlite3 -noheader -separator ' ' "$DB" \
76 "SELECT source || '+amend', COUNT(*) FROM incident_amendments
77 GROUP BY source ORDER BY source" >> before.txt 2>/dev/null || true
78 sqlite3 -noheader -separator ' ' "$DB" \
79 "SELECT source || '+raw', COUNT(*) FROM raw_records
80 GROUP BY source ORDER BY source" >> before.txt 2>/dev/null || true
81 sqlite3 -noheader -separator ' ' "$DB" \
82 "SELECT agency || '+search', COUNT(*) FROM alpr_searches
83 GROUP BY agency ORDER BY agency" >> before.txt 2>/dev/null || true
84 sqlite3 -noheader -separator ' ' "$DB" \
85 "SELECT source || '+raw', COUNT(*) FROM raw_records
86 GROUP BY source ORDER BY source" >> before.txt 2>/dev/null || true
87 sqlite3 -noheader -separator ' ' "$DB" \
88 "SELECT agency || '+search', COUNT(*) FROM alpr_searches
89 GROUP BY agency ORDER BY agency" >> before.txt 2>/dev/null || true
90 fi
91 cat before.txt
92
93 - name: Pull feeds
94 run: |
95 # A 30-day window cannot see an agency amending a record filed months
96 # ago, and OPD does exactly that, so sweep the whole feed on Sundays.
97 if [ "${{ inputs.full }}" = "true" ] || [ "${{ inputs.bootstrap }}" = "true" ] \
98 || [ "$(date -u +%u)" = "7" ]; then
99 echo "full sweep"
100 python ingest.py --full
101 else
102 python ingest.py
103 fi
104
105 - name: Check nothing was lost
106 run: |
107 sqlite3 -noheader -separator ' ' "$DB" \
108 "SELECT source, COUNT(*) FROM incidents GROUP BY source ORDER BY source" \
109 > after.txt
110 sqlite3 -noheader -separator ' ' "$DB" \
111 "SELECT source || '+amend', COUNT(*) FROM incident_amendments
112 GROUP BY source ORDER BY source" >> after.txt
113 sqlite3 -noheader -separator ' ' "$DB" \
114 "SELECT source || '+raw', COUNT(*) FROM raw_records
115 GROUP BY source ORDER BY source" >> after.txt
116 sqlite3 -noheader -separator ' ' "$DB" \
117 "SELECT agency || '+search', COUNT(*) FROM alpr_searches
118 GROUP BY agency ORDER BY agency" >> after.txt
119 cat after.txt
120 test -s after.txt || { echo "::error::archive is empty"; exit 1; }
121 # Keyed on FILENAME, not NR == FNR: before.txt is empty on a bootstrap
122 # run, and awk never resets FNR for a zero-length file.
123 awk -v first=before.txt '
124 FILENAME == first { was[$1] = $2; next }
125 { now[$1] = $2 }
126 END {
127 for (s in was)
128 if (now[s] + 0 < was[s] + 0) {
129 printf "::error::%s lost rows: %d -> %d\n", s, was[s], now[s]
130 bad = 1
131 }
132 exit bad
133 }' before.txt after.txt
134
135 # An amendment identical to its original means the digest drifted
136 # against what SQLite stores, and every run would file the same
137 # phantom again. Cheap to check, silent and cumulative if it happens.
138 phantom=$(sqlite3 -noheader "$DB" "
139 SELECT COUNT(*) FROM incident_amendments a
140 JOIN incidents o ON o.source = a.source AND o.source_key = a.source_key
141 WHERE a.agency IS o.agency AND a.case_id IS o.case_id
142 AND a.occurred_at IS o.occurred_at AND a.category IS o.category
143 AND a.call_type IS o.call_type AND a.disposition IS o.disposition
144 AND a.offense_desc IS o.offense_desc AND a.is_stop IS o.is_stop
145 AND a.address IS o.address AND a.lat IS o.lat AND a.lon IS o.lon")
146 if [ "$phantom" -ne 0 ]; then
147 echo "::error::$phantom amendments are identical to their original"
148 exit 1
149 fi
150
151 - name: Publish archive
152 run: |
153 sqlite3 "$DB" "VACUUM;"
154 gzip -c "$DB" > metro.db.gz
155 gh release view "$TAG" >/dev/null 2>&1 \
156 || gh release create "$TAG" --title "Incident archive" --notes "building"
157 gh release upload "$TAG" metro.db.gz --clobber
158 {
159 echo "SQLite archive of Omaha metro police incident feeds, rebuilt daily."
160 echo "Sarpy County and Council Bluffs publish a rolling 12-month window,"
161 echo "so this holds records their own feeds no longer serve."
162 echo
163 echo "Updated $(date -u '+%Y-%m-%d %H:%M UTC'). Schema: schema.sql."
164 echo
165 echo "incidents holds each record as first published; every later"
166 echo "version the feed served is a row in incident_amendments"
167 echo "($(sqlite3 -noheader "$DB" 'SELECT COUNT(*) FROM incident_amendments') so far)."
168 echo "incidents_current is the newest version of each, and"
169 echo "raw_records keeps the feed's own JSON for every version"
170 echo "so a parse can be redone against what actually arrived."
171 echo
172 echo '```'
173 sqlite3 -header -column "$DB" \
174 "SELECT agency, COUNT(*) AS rows, SUM(is_stop) AS stops,
175 MIN(occurred_at) AS earliest, MAX(occurred_at) AS latest
176 FROM incidents GROUP BY agency ORDER BY rows DESC"
177 echo '```'
178 } > notes.md
179 gh release edit "$TAG" --notes-file notes.md
180
181 # Runs after the upload on purpose: a feed that stopped updating should
182 # raise the alarm without also blocking the archive from being published.
183 - name: Check the feeds are still moving
184 run: |
185 sqlite3 -noheader "$DB" \
186 "SELECT source || ' ' || MAX(occurred_at) FROM incidents
187 WHERE source <> 'opd_csv' GROUP BY source
188 HAVING MAX(occurred_at) < datetime('now', '-7 days')" > stale.txt
189 if [ -s stale.txt ]; then
190 while read -r line; do echo "::error::feed is stale: $line"; done < stale.txt
191 exit 1
192 fi
193 echo "all feeds current"
194
195 - name: Summary
196 if: always()
197 run: |
198 {
199 echo "| source | before | after |"
200 echo "|---|---|---|"
201 awk -v first=before.txt '
202 FILENAME == first { was[$1] = $2; next }
203 { printf "| %s | %s | %s |\n", $1, ($1 in was ? was[$1] : 0), $2 }' \
204 before.txt after.txt
205 } >> "$GITHUB_STEP_SUMMARY"