Every tag, automatically.
Auto-generated feed of every released version. New tags land here automatically. For the curated narrative on what each release means, see the changelog.
Release timeline
Every entry below is a real Git tag from the source repository. When a tag is published, our CI notifies this site and the release is filed here for review. Curated language gets edited where it matters; everything else passes through with the original release notes intact. Links back into the source repository are stripped at render time because the repository itself is private — what you see is the work, not a pointer to the code.
The hourly poll is a fallback in case a webhook is missed. A tag normally lands within minutes of being cut; if the webhook drops, the next poll picks it up. The three things you might want elsewhere: the changelog bundles releases into the narrative of what shipped and why, the status page shows engineering pulse (commit heatmap, open work, CI runtime), and individual GitHub release URLs are intentionally omitted — the source isn’t public. The iPhone app is in TestFlight, by request, and the Android app is on Google Play. Request iPhone access · Get it on Google Play Email beta@snapshotscoring.com if a build will not install.
Versioning follows semver: MAJOR.MINOR.PATCH. Major bumps mean breaking changes on the wire (mobile app or WordPress plugin), minor bumps add features without breaking older clients, and patch bumps are bug fixes only. The pipeline auto-versions from main; commit messages tagged feat: trigger a minor bump, fix: a patch, BREAKING CHANGE a major. Releases are tested against the production API on real iOS and Android devices via Maestro before they’re cut, and CI gates merging to main with a Django backend test suite, an iOS E2E run, an Android E2E run, and a CodeQL security scan.
v1.9.36
What’s Changed
- Validate duplicate score creates (GlitchTip #304) by @ViolatedSine in https://github.com/the source repo/pull/273
v1.9.36
What’s Changed
- Validate duplicate score creates (GlitchTip #304) by @ViolatedSine in https://github.com/the source repo/pull/273
v1.9.35
Release v1.9.35 (scheduled)
v1.9.34
test(e2e): stop asserting an iOS pop that never happens (stage 23)
Stage 23 was the last red flow: 26 passed / 1 failed, with the persistence gate
green, across three consecutive runs.
- back does not pop on iOS — established earlier by the command’s own
duration (80ms on iOS vs 1.4-2.1s on Android for the real animation). The
header-back-button idiom that fixed stage 22 does not work from here either:
MatchDetailScreen retitles itself at runtime to the match name, and when that
title is too wide for the bar UIKit drops the back LABEL entirely, leaving a
bare chevron with no matchable text. Confirmed against the live run — the
selector interpolated correctly to “E2E Match 0829161824|Match|Back” and
matched nothing, so it was not a variable problem.
On iOS the flow now returns to the list by tapping the Matches tab, which is
reachable from any stack depth — exactly what stages 15 and 24 already do and
pass. The per-hop assertions stay on ANDROID, where the race they guard against
(a second back landing on the tab root) is real.
It deliberately does NOT assert matchDetailScreen on iOS. Asserting a pop the
platform never performs would be a test asserting fiction — which is the same
failure that let three bugs ship today behind passing tests. Stage 09 covers
match detail on both platforms.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.34
test(e2e): stop asserting an iOS pop that never happens (stage 23)
Stage 23 was the last red flow: 26 passed / 1 failed, with the persistence gate
green, across three consecutive runs.
- back does not pop on iOS — established earlier by the command’s own
duration (80ms on iOS vs 1.4-2.1s on Android for the real animation). The
header-back-button idiom that fixed stage 22 does not work from here either:
MatchDetailScreen retitles itself at runtime to the match name, and when that
title is too wide for the bar UIKit drops the back LABEL entirely, leaving a
bare chevron with no matchable text. Confirmed against the live run — the
selector interpolated correctly to “E2E Match 0829161824|Match|Back” and
matched nothing, so it was not a variable problem.
On iOS the flow now returns to the list by tapping the Matches tab, which is
reachable from any stack depth — exactly what stages 15 and 24 already do and
pass. The per-hop assertions stay on ANDROID, where the race they guard against
(a second back landing on the tab root) is real.
It deliberately does NOT assert matchDetailScreen on iOS. Asserting a pop the
platform never performs would be a test asserting fiction — which is the same
failure that let three bugs ship today behind passing tests. Stage 09 covers
match detail on both platforms.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.33
fix(results-plugin): restore the PHPStan gate’s teeth on snapshot_monitored (#263)
7b3dcaf3 showed the gate did not catch a fresh #295 in snapshot-admin-tools
because 35e7f41f had left monitored($callback) untyped. The same hole exists in
snapshot-results, and #262 is what opened it: to silence PHPStan calling the new
is_callable() guard unreachable, that PR weakened @param callable to @param
mixed. Measured on this tree — a dead mapping at init was reported CLEAN.
So #262 fixed the runtime fatal and disabled the static check that had just
found the trap. Both halves are needed and they pull against each other.
Resolved the way 7b3dcaf3 resolved it for the staff plugin: keep @param callable
so PHPStan can check the [Class, ‘method’] arrays, keep the is_callable() guard
as the runtime seatbelt, and ignore the resulting “unreachable ternary” in
phpstan.neon with the reasoning recorded. Deliberately PHPDoc, not a native
hint — a native hint throws the very TypeError the wrapper prevents.
Verified against this baseline: clean tree reports 0; a dead mapping at init
reports 1, naming Snapshot_Shortcodes::method_that_does_not_exist at the call
site. php -l clean on 8.0 and 8.3.
Baseline 321 -> 322: the Snapshot_REST::monitored(string $method) dynamic
callable is a genuine finding again now that the annotation is back. It marks
the remaining latent trap and should stay visible.
Claude-Session: https://claude.ai/code/session_01PKB8Yct2jDWE2b7YdRMHuG
Co-authored-by: Travis Nyhoff Co-authored-by: Claude Opus 5 (1M context)
v1.9.33
fix(results-plugin): restore the PHPStan gate’s teeth on snapshot_monitored (#263)
7b3dcaf3 showed the gate did not catch a fresh #295 in snapshot-admin-tools
because 35e7f41f had left monitored($callback) untyped. The same hole exists in
snapshot-results, and #262 is what opened it: to silence PHPStan calling the new
is_callable() guard unreachable, that PR weakened @param callable to @param
mixed. Measured on this tree — a dead mapping at init was reported CLEAN.
So #262 fixed the runtime fatal and disabled the static check that had just
found the trap. Both halves are needed and they pull against each other.
Resolved the way 7b3dcaf3 resolved it for the staff plugin: keep @param callable
so PHPStan can check the [Class, ‘method’] arrays, keep the is_callable() guard
as the runtime seatbelt, and ignore the resulting “unreachable ternary” in
phpstan.neon with the reasoning recorded. Deliberately PHPDoc, not a native
hint — a native hint throws the very TypeError the wrapper prevents.
Verified against this baseline: clean tree reports 0; a dead mapping at init
reports 1, naming Snapshot_Shortcodes::method_that_does_not_exist at the call
site. php -l clean on 8.0 and 8.3.
Baseline 321 -> 322: the Snapshot_REST::monitored(string $method) dynamic
callable is a genuine finding again now that the annotation is back. It marks
the remaining latent trap and should stay visible.
Claude-Session: https://claude.ai/code/session_01PKB8Yct2jDWE2b7YdRMHuG
Co-authored-by: Travis Nyhoff Co-authored-by: Claude Opus 5 (1M context)
v1.9.32
feat(ops): provision_circuit_host –ends-on never — founding members are free for life
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.32
feat(ops): provision_circuit_host –ends-on never — founding members are free for life
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.31
feat(mobile): peer Wi-Fi sync, Stage 1 — host a match sync session on the range Wi-Fi, join by QR, push/pull score bundles with no internet
One phone hosts (react-native-tcp-socket server on a random port, 80-bit session token from
crypto.getRandomValues), others join by scanning the host’s QR (ip:port:token, manual code as
fallback), push their scores and receive the host’s merged bundle in the same round trip.
Merge never overwrites locked scores; new scores enter the local store with temp ids so the
existing offline queue reconciles them with the server later. Dependency-free HTTP/1.1 subset
with a real TCP loopback test in Jest. Removes the non-functional LocalSyncContext /
localNetworkSync stub. iOS: NSLocalNetworkUsageDescription (pod install needed); Android: raw
TCP client, no cleartext exemption. Full mobile suite 6809 passed.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.31
feat(mobile): peer Wi-Fi sync, Stage 1 — host a match sync session on the range Wi-Fi, join by QR, push/pull score bundles with no internet
One phone hosts (react-native-tcp-socket server on a random port, 80-bit session token from
crypto.getRandomValues), others join by scanning the host’s QR (ip:port:token, manual code as
fallback), push their scores and receive the host’s merged bundle in the same round trip.
Merge never overwrites locked scores; new scores enter the local store with temp ids so the
existing offline queue reconciles them with the server later. Dependency-free HTTP/1.1 subset
with a real TCP loopback test in Jest. Removes the non-functional LocalSyncContext /
localNetworkSync stub. iOS: NSLocalNetworkUsageDescription (pod install needed); Android: raw
TCP client, no cleartext exemption. Full mobile suite 6809 passed.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.30
refactor: consolidation pass — N+1 fixes, seats_available consistency, dead code, standings paging, owner-by-username, web dedup
Backend: club list prefetches seat/payer, owner ids and the requester’s membership (query
count now independent of club count; guarded by a scale test); AdminSubscriptionSerializer
uses the model’s seats_available (cooling seats were ignored → console showed free seats
move-seat refused) and subscription_in_grace; admin circuits list no longer counts seats per
row; CircuitSerializer.my_role prefetch wired; push_service fetches the match once; dead
BaseAdminViewSet / Club.default_circuit (migration 0054) / ClubPlans.ENTITLED removed and the
stale record-only docstring rewritten; ?competitor= on circuit standings.
Mobile: fetchSeasonStandings walks every page (seasons >50 entrants were truncated),
fetchMyStanding uses the competitor filter, pickCurrentSeason replaces a redundant request
per circuit in MatchCircuitScreen, owner field accepts usernames (the parser rejected them).
Web: clubs.ts announcement duplicates removed (announcements.ts is the home), fetchMatchResults
row mapper hoisted, bulk squad moves list groups once (+ tests).
Plugin: one operator error-copy table (SAT_Ui), duplicate .sat-badge rule merged.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.30
refactor: consolidation pass — N+1 fixes, seats_available consistency, dead code, standings paging, owner-by-username, web dedup
Backend: club list prefetches seat/payer, owner ids and the requester’s membership (query
count now independent of club count; guarded by a scale test); AdminSubscriptionSerializer
uses the model’s seats_available (cooling seats were ignored → console showed free seats
move-seat refused) and subscription_in_grace; admin circuits list no longer counts seats per
row; CircuitSerializer.my_role prefetch wired; push_service fetches the match once; dead
BaseAdminViewSet / Club.default_circuit (migration 0054) / ClubPlans.ENTITLED removed and the
stale record-only docstring rewritten; ?competitor= on circuit standings.
Mobile: fetchSeasonStandings walks every page (seasons >50 entrants were truncated),
fetchMyStanding uses the competitor filter, pickCurrentSeason replaces a redundant request
per circuit in MatchCircuitScreen, owner field accepts usernames (the parser rejected them).
Web: clubs.ts announcement duplicates removed (announcements.ts is the home), fetchMatchResults
row mapper hoisted, bulk squad moves list groups once (+ tests).
Plugin: one operator error-copy table (SAT_Ui), duplicate .sat-badge rule merged.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.29
feat(seats): repurchase auto-moves clubs off ending seats; 30-day cooldown on vacated seats
- from-order: a new subscription for a payer whose clubs sit on ended/ending seats moves
those clubs onto it (the website’s ‘buy again with the same email’ path, no staff step).
- One club at a time per seat: SeatRelease records a club leaving a seat (move-seat,
delete); seats_available subtracts seats cooling for CLUB_SEAT_COOLDOWN_DAYS (30).
move-seat accepts override_cooldown (staff, audited). Founding clubs → comped seats in
the enforcement runbook.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.29
feat(seats): repurchase auto-moves clubs off ending seats; 30-day cooldown on vacated seats
- from-order: a new subscription for a payer whose clubs sit on ended/ending seats moves
those clubs onto it (the website’s ‘buy again with the same email’ path, no staff step).
- One club at a time per seat: SeatRelease records a club leaving a seat (move-seat,
delete); seats_available subtracts seats cooling for CLUB_SEAT_COOLDOWN_DAYS (30).
move-seat accepts override_cooldown (staff, audited). Founding clubs → comped seats in
the enforcement runbook.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.28
feat(admin-api): attach an existing circuit to a circuit_host subscription
Circuits created before the seat model (the Nebraska circuit) had no host; provision-circuit
only creates new ones. POST /admin/subscriptions/{id}/attach-circuit/ binds an existing circuit
and makes the payer its owner; 409 if either side is already hosted.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.28
feat(admin-api): attach an existing circuit to a circuit_host subscription
Circuits created before the seat model (the Nebraska circuit) had no host; provision-circuit
only creates new ones. POST /admin/subscriptions/{id}/attach-circuit/ binds an existing circuit
and makes the payer its owner; 409 if either side is already hosted.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.27
feat(seats): website checkout → subscriptions — from-order webhook, event ledger, claim-by-email
POST /api/admin/subscriptions/from-order/ (service account, clubs:write): idempotent on
(source, event_id); first activate for an external_key creates the subscription (payer by
id/email; unknown email held as claim_email and attached on registration or first
/me/seats read), later activates extend the term (renewals), revoke ends it today.
SubscriptionEvent records every delivery; audit rows as usual. Subscription gains
source / external_key / claim_email. Tests cover create, renew, duplicate, claim, revoke.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.27
feat(seats): website checkout → subscriptions — from-order webhook, event ledger, claim-by-email
POST /api/admin/subscriptions/from-order/ (service account, clubs:write): idempotent on
(source, event_id); first activate for an external_key creates the subscription (payer by
id/email; unknown email held as claim_email and attached on registration or first
/me/seats read), later activates extend the term (renewals), revoke ends it today.
SubscriptionEvent records every delivery; audit rows as usual. Subscription gains
source / external_key / claim_email. Tests cover create, renew, duplicate, claim, revoke.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
v1.9.26
chore(store-policy): no purchase steering in the app — gate and read-only copy state facts only
Plans are sold on the website, never in the app. App Store 3.1.3 / Play Payments forbid
calls to action toward outside purchases, so ‘Contact SnapshotScoring to set one up’ and
‘…to renew’ become plain statements. docs/SEATS.md records the policy and review notes.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01NHxqBcVXDSGh6V7nwa9QTU
The story behind each release.
This page lists every tag. The changelog groups them into the work that mattered — what landed, why, and what’s next. The status page has the live engineering pulse — commit heatmap, open work, CI runtime.