Skip to content

Commit 9bef681

Browse files
committed
CI: build every branch, survey the bus fleet, screenshot a branch build
- Build APK runs on every push; only main publishes the release - tools/fleet_survey.py lists the vehicle labels on the road and any the fleet table doesn't recognise (runs as a non-failing build step) - Screenshots can build the branch it's run on, and prints small JPEGs into the log Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011C2fAvFFcMRkaF7qSvRA1v
1 parent 0077d56 commit 9bef681

3 files changed

Lines changed: 168 additions & 3 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
name: Build APK
22

3+
# Every branch gets a build (download it from the run's artifacts); only
4+
# main publishes to the "latest" release.
35
on:
46
push:
5-
branches: [main]
67
workflow_dispatch:
78

89
permissions:
@@ -35,6 +36,13 @@ jobs:
3536
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
3637
run: gradle :app:assembleRelease --no-daemon --stacktrace
3738

39+
- name: Fleet survey
40+
# which buses are on the road, and any the fleet table doesn't know yet
41+
continue-on-error: true
42+
env:
43+
AT_API_KEY: ${{ secrets.AT_API_KEY }}
44+
run: python3 tools/fleet_survey.py --raw
45+
3846
- name: Name the APK
3947
run: cp app/build/outputs/apk/release/app-release.apk AKL-Live.apk
4048

@@ -44,6 +52,7 @@ jobs:
4452
path: AKL-Live.apk
4553

4654
- name: Publish to the "latest" release
55+
if: github.ref == 'refs/heads/main'
4756
env:
4857
GH_TOKEN: ${{ github.token }}
4958
run: |

‎.github/workflows/screenshots.yml‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
name: Screenshots
22

3-
# Boots an emulator, installs the latest release APK, drives through the
4-
# screens with live data and saves screenshots + the crash log.
3+
# Boots an emulator, installs the app, drives through the screens with live
4+
# data and saves screenshots + the crash log. By default it shoots the latest
5+
# release; "build" shoots the branch it's run on instead.
56
on:
67
workflow_dispatch:
8+
inputs:
9+
apk:
10+
description: "release = latest published APK, build = build this branch"
11+
default: release
12+
type: choice
13+
options: [release, build]
714

815
jobs:
916
shots:
@@ -12,10 +19,30 @@ jobs:
1219
- uses: actions/checkout@v4
1320

1421
- name: Latest APK
22+
if: inputs.apk != 'build'
1523
env:
1624
GH_TOKEN: ${{ github.token }}
1725
run: gh release download latest -p AKL-Live.apk -R ${{ github.repository }}
1826

27+
- uses: actions/setup-java@v4
28+
if: inputs.apk == 'build'
29+
with:
30+
distribution: temurin
31+
java-version: 17
32+
33+
- uses: gradle/actions/setup-gradle@v4
34+
if: inputs.apk == 'build'
35+
with:
36+
gradle-version: "8.11.1"
37+
38+
- name: Build this branch
39+
if: inputs.apk == 'build'
40+
env:
41+
AT_API_KEY: ${{ secrets.AT_API_KEY }}
42+
run: |
43+
gradle :app:assembleRelease --no-daemon --stacktrace
44+
cp app/build/outputs/apk/release/app-release.apk AKL-Live.apk
45+
1946
- name: Enable KVM
2047
run: |
2148
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
@@ -36,3 +63,16 @@ jobs:
3663
with:
3764
name: screenshots
3865
path: shots/
66+
67+
- name: Screenshots in the log
68+
# small JPEGs as base64, for reviewing a run without downloading it
69+
if: always()
70+
run: |
71+
command -v convert > /dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq imagemagick)
72+
cat shots/crash.txt || true
73+
for f in shots/*.png; do
74+
convert "$f" -resize 480x -quality 72 /tmp/shot.jpg
75+
echo "=== SHOT $(basename "$f" .png) ==="
76+
base64 -w 0 /tmp/shot.jpg; echo
77+
echo "=== END ==="
78+
done

‎tools/fleet_survey.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Surveys every bus on the road right now and checks it against the app's
2+
# fleet table (app/src/main/assets/fleet.tsv), so the "what bus is coming"
3+
# lookup can be kept up to date as operators add buses.
4+
#
5+
# AT_API_KEY=... python tools/fleet_survey.py
6+
#
7+
# Prints the label formats in use, a sample of vehicles per operator prefix,
8+
# and every label the fleet table doesn't recognise, grouped into ranges.
9+
import collections, json, os, re, sys, urllib.request
10+
11+
HERE = os.path.dirname(os.path.abspath(__file__))
12+
FLEET = os.path.join(HERE, "..", "app", "src", "main", "assets", "fleet.tsv")
13+
KEY = os.environ.get("AT_API_KEY", "")
14+
15+
16+
def api(path):
17+
req = urllib.request.Request("https://api.at.govt.nz" + path,
18+
headers={"Ocp-Apim-Subscription-Key": KEY,
19+
"Accept": "application/json"})
20+
return json.load(urllib.request.urlopen(req, timeout=60))
21+
22+
23+
def load_fleet():
24+
"""Rows of (prefixes, first, last, operator, model, kind)."""
25+
rows = []
26+
if not os.path.exists(FLEET):
27+
return rows
28+
for line in open(FLEET, encoding="utf-8"):
29+
line = line.rstrip("\n")
30+
if not line or line.startswith("#"):
31+
continue
32+
p = line.split("\t")
33+
rows.append((p[0].split(","), int(p[1]), int(p[2]), p[3], p[4], p[5] if len(p) > 5 else ""))
34+
return rows
35+
36+
37+
def split_label(label):
38+
"""'TR3881' -> ('TR', 3881); 'NB 3563' -> ('NB', 3563)."""
39+
m = re.match(r"^\s*([A-Za-z]*)\s*0*(\d+)", label or "")
40+
return (m.group(1).upper(), int(m.group(2))) if m else ("", None)
41+
42+
43+
def lookup(fleet, prefix, num):
44+
for pre, a, b, op, model, kind in fleet:
45+
if (prefix in pre or "*" in pre) and a <= num <= b:
46+
return op, model
47+
return None
48+
49+
50+
def ranges(nums):
51+
nums = sorted(set(nums))
52+
out, start, prev = [], None, None
53+
for n in nums:
54+
if start is None:
55+
start = prev = n
56+
elif n <= prev + 3:
57+
prev = n
58+
else:
59+
out.append((start, prev)); start = prev = n
60+
if start is not None:
61+
out.append((start, prev))
62+
return ", ".join(str(a) if a == b else "%d-%d" % (a, b) for a, b in out)
63+
64+
65+
def main():
66+
if not KEY:
67+
print("No AT_API_KEY, skipping the survey")
68+
return
69+
fleet = load_fleet()
70+
j = api("/realtime/legacy/vehiclelocations")
71+
ents = j.get("response", {}).get("entity", [])
72+
by_prefix = collections.defaultdict(list)
73+
keys = collections.Counter()
74+
for e in ents:
75+
v = e.get("vehicle") or {}
76+
info = v.get("vehicle") or {}
77+
keys.update(info.keys())
78+
vid = str(info.get("id", ""))
79+
if vid.startswith("59"): # trains
80+
continue
81+
label = re.sub(r"\s+", " ", str(info.get("label", ""))).strip()
82+
route = ((v.get("trip") or {}).get("route_id") or "").split("-")[0]
83+
by_prefix[split_label(label)[0]].append((label, vid, info.get("license_plate", ""), route))
84+
85+
print("vehicles: %d, vehicle descriptor keys: %s" % (len(ents), dict(keys)))
86+
if "--raw" in sys.argv:
87+
buses = [e for e in ents if not str(((e.get("vehicle") or {}).get("vehicle") or {}).get("id", "")).startswith("59")]
88+
for e in buses[:3]:
89+
print(json.dumps(e))
90+
print()
91+
print("%-6s %5s %-44s %s" % ("prefix", "count", "fleet numbers", "sample (label | id | plate | route)"))
92+
for pre, vs in sorted(by_prefix.items(), key=lambda kv: -len(kv[1])):
93+
nums = [split_label(l)[1] for l, _, _, _ in vs if split_label(l)[1] is not None]
94+
sample = "; ".join("%s | %s | %s | %s" % s for s in vs[:4])
95+
print("%-6s %5d %-44s %s" % (pre or "-", len(vs), ranges(nums)[:44], sample))
96+
97+
print()
98+
unknown = collections.defaultdict(list)
99+
known = 0
100+
for pre, vs in by_prefix.items():
101+
for label, _, _, route in vs:
102+
_, n = split_label(label)
103+
if n is not None and lookup(fleet, pre, n):
104+
known += 1
105+
else:
106+
unknown[pre].append((n, route))
107+
total = sum(len(v) for v in by_prefix.values())
108+
print("fleet table recognises %d of %d buses" % (known, total))
109+
for pre, vs in sorted(unknown.items()):
110+
nums = [n for n, _ in vs if n is not None]
111+
routes = collections.Counter(r for _, r in vs).most_common(8)
112+
print(" unknown %-4s %s (routes: %s)" % (pre or "-", ranges(nums), ", ".join(r for r, _ in routes)))
113+
114+
115+
if __name__ == "__main__":
116+
main()

0 commit comments

Comments
 (0)