diff --git a/.github/workflows/artifact-sweep.yaml b/.github/workflows/artifact-sweep.yaml new file mode 100644 index 0000000..78ca285 --- /dev/null +++ b/.github/workflows/artifact-sweep.yaml @@ -0,0 +1,35 @@ +name: Sweep expired Actions artifacts + +# Safety net for issue #18: release-action.yaml's own artifacts are +# deleted immediately once they land in a release, but stray builds that +# never go through that job (feature branches, force-rebuilds, manual +# workflow_dispatch runs someone kicked off and forgot about) just sit +# there. GitHub's own retention-days cleanup can lag by weeks in +# practice -- confirmed 2026-09-05, 12.1GiB of artifacts sitting around +# up to three weeks past their own expiry -- so this sweeps anything +# already past expires_at rather than trusting GitHub to do it. +on: + schedule: + - cron: '0 5 * * 0' # weekly, Sunday 05:00 UTC + workflow_dispatch: {} + +jobs: + sweep: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Delete artifacts past their own expiry + env: + GH_TOKEN: ${{ github.token }} + run: | + NOW=$(date -u +%s) + gh api "repos/${{ github.repository }}/actions/artifacts" --paginate \ + --jq '.artifacts[] | [.id, .expires_at] | @tsv' | while IFS=$'\t' read -r id expires_at; do + [[ -z "$expires_at" || "$expires_at" == "null" ]] && continue + expires_epoch=$(date -u -d "$expires_at" +%s 2>/dev/null || echo 0) + if (( expires_epoch > 0 && expires_epoch < NOW )); then + echo "Deleting expired artifact $id (expired $expires_at)" + gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" || true + fi + done diff --git a/.github/workflows/release-action.yaml b/.github/workflows/release-action.yaml index 2da3c9c..3161a14 100644 --- a/.github/workflows/release-action.yaml +++ b/.github/workflows/release-action.yaml @@ -139,6 +139,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + actions: write steps: - name: Checkout repository @@ -180,3 +181,20 @@ jobs: --notes-file /tmp/release-notes.md \ release-assets/*.img.gz \ /tmp/buttons-version.txt + + # Once the images are attached to the release as real assets, the + # raw CI artifacts this job downloaded from `build` serve no purpose + # -- delete them immediately rather than let retention-days expire + # them on GitHub's own timeline (issue #18: found 12.1GiB of already- + # expired-but-uncollected artifacts sitting around, silently + # billing against the account's Actions storage cap). + - name: Delete this run's CI artifacts (now redundant with the release) + if: success() + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \ + --jq '.artifacts[].id' | while read -r id; do + echo "Deleting artifact $id" + gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" || true + done diff --git a/ACTION-PLAN.md b/ACTION-PLAN.md index a3e7c42..636b2a0 100644 --- a/ACTION-PLAN.md +++ b/ACTION-PLAN.md @@ -111,6 +111,13 @@ falling back to splash only if none is." Solving that cleanly handles both the the right thing should win automatically" case (#12) with one mechanism instead of two bolted-on fixes that could disagree with each other. +**Implemented 2026-09-05**, in `scripts/install-deck-splash.sh`: +- `OnFailure=dpx-deck-splash.service` drop-ins (`/etc/systemd/system/.service.d/dpx-recovery.conf`) on all three mode units. Drop-ins, not direct edits, since all three ship from vendor `.deb`s, not this repo — survives a package upgrade. +- `dpx-mode-select.service` (new oneshot, `WantedBy=multi-user.target`): reads `/etc/dpx-mode` at boot and starts exactly that one mode service, falling back to `dpx-deck-splash.service` if nothing's persisted or the target refuses to start. +- `dpx-deck-splash.service` no longer auto-enabled — it's only ever started by the fallback above or by an `OnFailure` recovery, never racing the mode service for `multi-user.target` on its own. +- **Not yet live-verified** (no device access this pass) — needs a real boot-cycle test: confirm the persisted mode wins every time, and force a mode service into permanent failure (e.g. `systemctl kill` past its restart burst) to confirm splash actually comes back. +- Also worth re-checking against this fix once live: the reported "device is already in a mode but not started, hitting GO does not start the thing" symptom. `execute_staged()`'s `mode_dead` check in `dpx-deck-splash.py` already looks correct on paper (re-applies if the persisted mode's service isn't actually active) — this may already have been a downstream effect of the same boot race rather than a separate bug. Confirm rather than assume once testable. + Dashboard's own boot-time auto-start (the "and dashboard on/off" half of #12) is simpler and independent of the above — it's just "should `dpx-dashboard.service` be enabled or not," already a persisted systemd state via `set_dashboard_enabled()`, @@ -128,3 +135,41 @@ new for it. 5. #11 + #12 together (the real design work — biggest single piece here) 6. #17's "doesn't launch" half — verify once a fresh build exists (falls out of #11/#12 work naturally, since that's a rebuild anyway) 7. #10 and #17's "doesn't reflect state" half — both need live device access, batch them into one SSH session once available + +--- + +## #18 — GitHub Actions artifact storage cleanup (housekeeping) + +**2026-09-05: found and fixed once.** `gh api repos/.../actions/artifacts` showed +10 artifacts totaling 12.1 GiB, ALL already past their `retention-days: 3` +expiration (the oldest by three weeks) but never garbage-collected by GitHub — +they were still billing against the 2GB storage cap the whole time. Deleted +manually via `gh api -X DELETE .../actions/artifacts/`, storage now at 0. + +Not a one-time cleanup — this will silently refill: `release-action.yaml`'s +nightly cron (`0 6 * * *`) builds new images whenever the Buttons mirror has an +unreleased version, `armbian-builder.yaml`/`raspios-builder.yaml` upload +1-1.7GB artifacts per board/variant, and the `release` job downloads them into +a GitHub Release but never deletes the source CI artifacts afterward — nothing +sweeps them once `retention-days` lapses, they just sit there until someone +notices. + +**Squash it down properly, don't just re-delete manually next time:** +- Add a step at the end of `release-action.yaml`'s `release` job (after the + release is successfully created) that deletes the just-downloaded build + artifacts immediately via `gh api -X DELETE` — once they're in the + release as `.img.gz` assets, the raw CI artifacts serve no purpose. +- Consider a small separate scheduled workflow (e.g. weekly) that lists and + deletes any artifact past its `expires_at`, as a safety net for stray + manual/debug-branch builds (feature branches, force-rebuilds) that don't + go through the release job at all. +- Low priority relative to #10-#17, but cheap to build once — fold into + the work whenever convenient, or do it standalone. + +**Implemented 2026-09-05**: `release-action.yaml`'s `release` job now deletes +its own run's CI artifacts immediately after the release is created +(`actions: write` added to its permissions). New `artifact-sweep.yaml` +workflow runs weekly (Sunday 05:00 UTC) plus `workflow_dispatch`, deleting +any artifact anywhere in the repo already past its own `expires_at` — the +same category of already-expired-but-uncollected artifact found and +manually cleared this session. diff --git a/images/009_deck_splash.png b/images/009_deck_splash.png deleted file mode 100644 index aceafc3..0000000 Binary files a/images/009_deck_splash.png and /dev/null differ diff --git a/scripts/install-dashboard.sh b/scripts/install-dashboard.sh index cd75d70..439a66c 100755 --- a/scripts/install-dashboard.sh +++ b/scripts/install-dashboard.sh @@ -86,7 +86,7 @@ xset s off xset s noblank unclutter -idle 0.5 -root & openbox-session & -exec companion-dashboard --kiosk --no-sandbox +exec companion-dashboard --kiosk-mode --no-sandbox XINITRC chmod +x "$DASH_HOME/.xinitrc" diff --git a/scripts/install-deck-splash.sh b/scripts/install-deck-splash.sh index 712bbda..0ef2767 100755 --- a/scripts/install-deck-splash.sh +++ b/scripts/install-deck-splash.sh @@ -105,8 +105,74 @@ KillMode=process WantedBy=multi-user.target UNIT -systemctl enable dpx-deck-splash.service -echo "==> dpx-deck-splash.service: enabled" +# NOT enabled directly. dpx-mode-select.service (below) is now the only +# thing that starts this at boot -- only as the no-persisted-mode +# fallback -- instead of both it and the current mode service racing +# multi-user.target with Conflicts= picking whichever happens to win +# (dpx#12, confirmed nondeterministic on hardware). The [Install] block +# stays so `systemctl enable dpx-deck-splash.service` still works for +# anyone who wants the old always-auto-start behavior back. +echo "==> dpx-deck-splash.service: installed (started via dpx-mode-select.service, not auto-enabled)" + +# ── Recovery: bring the splash back if a mode service dies for good ──────── +# OnFailure= only fires when a unit's ActiveState actually reaches +# "failed" -- with Restart=on-failure, systemd holds the unit in +# "activating (auto-restart)" between individual retry attempts, and +# only lands in "failed" once StartLimitBurst is exhausted. So this +# fires once per real, permanent outage, not once per transient restart +# (dpx#11 -- "what's not clear is when the splash comes back"). Purely +# event-driven, no polling loop. +# +# Drop-ins, not edits to the vendor unit files themselves -- all three +# mode services ship from their own .deb packages (Buttons/Satellite/ +# Companion), not this repo, and a drop-in survives a package upgrade +# that a direct edit wouldn't. +for MODE_UNIT in bitfocus-buttons-usb-relay.service satellite.service companion.service; do + mkdir -p "/etc/systemd/system/${MODE_UNIT}.d" + cat > "/etc/systemd/system/${MODE_UNIT}.d/dpx-recovery.conf" << 'UNIT' +[Unit] +OnFailure=dpx-deck-splash.service +UNIT +done +echo "==> OnFailure=dpx-deck-splash.service drop-ins installed for all 3 mode services" + +# ── Boot-time mode selection: exactly one of {persisted mode, splash} ────── +# The other half of dpx#12/dpx#11: decide once, at boot, which single +# thing should run instead of leaving it to a Conflicts= race. Reads +# /etc/dpx-mode (same file switch_mode() in dpx-buttonode-ui.py writes) +# and starts that mode's service; falls back to the splash if nothing's +# persisted or the target service refuses to start. Mirrors +# get_dpx_mode()'s own "buttons" default for consistency. +cat > /usr/local/bin/dpx-mode-select.sh << 'SCRIPT' +#!/usr/bin/env bash +set -u +MODE="$(cat /etc/dpx-mode 2>/dev/null || echo "buttons")" +case "$MODE" in + buttons) SVC="bitfocus-buttons-usb-relay.service" ;; + satellite) SVC="satellite.service" ;; + companion) SVC="companion.service" ;; + *) SVC="bitfocus-buttons-usb-relay.service" ;; +esac +systemctl start "$SVC" || systemctl start dpx-deck-splash.service +SCRIPT +chmod +x /usr/local/bin/dpx-mode-select.sh + +cat > /etc/systemd/system/dpx-mode-select.service << 'UNIT' +[Unit] +Description=Start the persisted dpx-buttonode mode (fallback: deck splash) +Documentation=https://github.com/dubpixel/dpx_buttonode +After=dpx-set-hostname.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/dpx-mode-select.sh + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl enable dpx-mode-select.service +echo "==> dpx-mode-select.service: enabled" # ── sudoers: the ONLY door from dpx-splash (buttons group, nothing else) # to actually changing system state ───────────────────────────────────────── diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 84bb7d3..11d3918 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -279,6 +279,58 @@ def write_networkd_config(iface, mode, ip_cidr=None, gateway=None, dns="8.8.8.8" "systemctl", "restart", "dpx-buttonode-ui"]) +def write_nmcli_config(iface, mode, ip_cidr=None, gateway=None, dns="8.8.8.8"): + """Apply network config through NetworkManager. `nmcli connection + modify` writes the change straight to the connection's on-disk + profile (/etc/NetworkManager/system-connections/*.nmconnection), so + unlike the networkd path there's no separate config file to manage — + the same command that applies it live is what makes it persist.""" + out, _, _ = run(["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"]) + conn = "" + for line in out.splitlines(): + parts = line.split(":") + if len(parts) >= 2 and "ethernet" in parts[1].lower(): + conn = parts[0] + break + if not conn: + return + if mode == "dhcp": + run(["nmcli", "connection", "modify", conn, + "ipv4.method", "auto", + "ipv4.addresses", "", + "ipv4.gateway", "", + "ipv4.dns", ""]) + else: + run(["nmcli", "connection", "modify", conn, + "ipv4.method", "manual", + "ipv4.addresses", ip_cidr, + "ipv4.gateway", gateway, + "ipv4.dns", dns]) + run(["nmcli", "connection", "up", conn]) + run(["systemctl", "reload-or-restart", "avahi-daemon"]) + active_svc = { + "buttons": "bitfocus-buttons-usb-relay", + "satellite": "satellite", + "companion": "companion", + }.get(get_dpx_mode(), "bitfocus-buttons-usb-relay") + run(["systemctl", "restart", active_svc]) + run(["systemd-run", "--no-block", "--quiet", + "systemctl", "restart", "dpx-buttonode-ui"]) + + +def apply_net_config(iface, mode, ip_cidr=None, gateway=None, dns="8.8.8.8"): + """Persist network config through whichever backend actually manages + this interface. Raspberry Pi OS defaults to NetworkManager; Armbian + defaults to systemd-networkd/Netplan. Writing networkd files on an + nmcli-managed box doesn't survive reboot — NetworkManager reasserts + its own connection profile on boot, reverting straight back to DHCP + (dpx#14) — so the two paths need picking, not just one used blindly.""" + if nmcli_available(): + write_nmcli_config(iface, mode, ip_cidr, gateway, dns) + else: + write_networkd_config(iface, mode, ip_cidr, gateway, dns) + + def toggle_net(): """Flip DHCP<->static. No argument needed — a caller with no way to type an address (a deck keypress) should have nothing to get wrong. @@ -301,9 +353,9 @@ def toggle_net(): if current["mode"] == "dhcp": if not current.get("gateway"): return False, "No gateway detected — can't safely pin a static config" - write_networkd_config(iface, "static", current["ip_cidr"], current["gateway"], current["dns"]) + apply_net_config(iface, "static", current["ip_cidr"], current["gateway"], current["dns"]) return True, f"Pinned static {current['ip_cidr']}" - write_networkd_config(iface, "dhcp") + apply_net_config(iface, "dhcp") return True, "Switched to DHCP" @@ -334,7 +386,7 @@ def pin_static(cidr_str): else: prefix = current["ip_cidr"].split("/")[-1] if "/" in current["ip_cidr"] else "24" ip_cidr = f"{ip_str}/{prefix}" - write_networkd_config(iface, "static", ip_cidr, current["gateway"], current["dns"]) + apply_net_config(iface, "static", ip_cidr, current["gateway"], current["dns"]) return True, f"Pinned static {ip_cidr}" @@ -648,8 +700,8 @@ def render_status(alert="", alert_cls="a-ok"):
Hostname
{host}
-
IP Address
-
{ip}
+
IP Address
+
{ip}
MAC
{mac}
Network
@@ -837,6 +889,7 @@ def dashboard_section(): {'
' if on else ''} + {f'⚙ Remote Config ↗' if on else ''}
"""