Activated the in-app Apply update flow for the GitHub channel — the discover-and-act loop is now complete. The "GitHub update" card on /updates, which v3.0.0 introduced in five visible states, now offers a real Apply update button in its update-available state. Click it and Aerodrome downloads the release zip and its .sha256 from the public asset URLs, verifies the checksum matches, stages the contents into update/, and hands off to the existing POST /api/updates/local/apply flow — which already handles sudoers drift pre-flight, backup, copy-over, dependency reinstall, and service restart. The apply step itself is unchanged; v3.0.1 is purely a fetch + verify + stage layer in front of infrastructure that already worked. With v3.0.0's check side and v3.0.1's apply side both live, the GitHub channel is now end-to-end self-sufficient — users no longer need to download a zip manually, SCP it to the server, or use the local-upload card to install a discovered update. Card order on /updates flipped to match: GitHub-update is now position 1, Local update is position 2, ntfy stays third, Changelog stays last. The page intro text dropped its "(soon) GitHub" qualifier (now stale), and the order reflects the new reality where GitHub is the primary update path and local upload is the air-gapped/manual fallback.
How it works for users: with an update available (i.e. the GitHub card showing update available badge), three buttons appear. Apply update (vX.Y.Z) is the primary action — click it, confirm the destructive-op prompt (downloads, checksums, backs up, replaces, restarts), and the install transitions through "applying…" → "applied" states with the standard "page will reload in a few seconds" recovery. View on GitHub stays as a secondary action for users who want to read the Release notes before committing to apply, useful especially for major versions where the notes might surface migration steps or behavior changes. Check again stays available for the user who clicks away and back. If the apply fails partway, the card surfaces the specific error and offers both Try again and Re-check status buttons — failures here include checksum mismatches (refused before staging — never apply an unverified zip), network timeouts during the 60-second per-asset download budget, GitHub returning 404 on an asset (a packaging bug worth reporting), GitHub returning 403 rate-limited (anonymous API limit, suggests waiting an hour), insufficient disk space, and the sudoers-drift case which inherits the existing modal from the local-apply flow automatically.
Behind the scenes — the reuse architecture. apply_local_update() was already source-agnostic about how files got into update/ — it just looks for update/VERSION or update/aerodrome/VERSION and proceeds. v3.0.1 doesn't refactor that function or even touch its signature; the new POST /api/updates/github/apply endpoint stages the GitHub-sourced content into update/, then literally calls await apply_local_update() and returns its response unchanged. Everything below the staging layer — sudoers drift check, the .backups/<timestamp>/ snapshot, the file-by-file copy, the pip install -r requirements.txt step, the systemd restart — runs identically whether the user came from local-upload or from the GitHub button. This means the UI's success-rendering can reuse one render path; the success/failure response shape from both endpoints is byte-identical. About 30 lines of UI changes for the apply flow specifically; the rest of the +200 lines in this release is the fetch + verify + stage helpers in server.py.
Behind the scenes — the staging design choices. The release zip ships with a top-level aerodrome-vX.Y.Z/ wrapper folder per the v2.98.2 packaging convention, but apply_local_update() expects either update/VERSION or update/aerodrome/VERSION at lookup time. The new _verify_and_stage_github_zip() handles this by detecting a common top-level prefix across all zip members and stripping it during extraction — so aerodrome-v3.0.1/server.py ends up at update/server.py, ready for the apply step. The detection is robust against both wrapped layouts (current) and any future flat layout (no prefix) without code changes. Disk-full during extraction surfaces as a clear "Not enough disk space to stage the update" rather than a half-extracted tree; checksum verification happens BEFORE any disk writes, so a corrupted download never touches update/ — the trust anchor for the channel runs at the right point in the pipeline. The update/ directory is wiped clean before extraction (except UPDATE_README.md and .gitkeep which the local-apply flow's own cleanup preserves), so any previously-staged local upload doesn't bleed into the GitHub apply — the two flows are isolated by design.
Behind the scenes — the dependency footprint. v3.0.1 adds three stdlib imports inside the endpoint registration scope (hashlib, zipfile, io) — no new package added to requirements.txt, consistent with v3.0.0's "use urllib for HTTP, no requests dependency on this path" choice. The 60-second per-asset timeout is twice v3.0.0's 30-second check-API timeout because the assets are larger (5 MB vs a few KB of JSON), but well below any reasonable session-stalling threshold. Apply gates on updates.github.enabled == true AND a real update being available (last_known_latest > running_version per semver) — a UI cache showing stale "update available" state can't trigger an unwanted apply because the endpoint re-verifies against the server's current SQLite state at request time.
What's next: v3.0.2 is the final piece of the v3.0.x arc — adds the optional ntfy push notification for update_available events (opt-in like all ntfy event types, requires both updates.github.notify.ntfy: true and notifications.events.update_available: true to actually fire), plus a polish pass on whatever rough edges surface during dogfooding the now-complete check-and-apply flow.