From 8eda77420f596eb03799a7a9d6a1fc5714393f82 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:52:31 -0400 Subject: [PATCH 1/7] docs: log Actions artifact cleanup (#18) in action plan Found 12.1 GiB of already-expired-but-uncollected artifacts via the API, deleted them manually, and logged the recurring fix (delete CI artifacts after they land in a release) as a housekeeping item. Co-Authored-By: Claude Sonnet 5 --- ACTION-PLAN.md | 30 ++++++++++++++++++++++++++++++ images/009_deck_splash.png | Bin 12676 -> 0 bytes 2 files changed, 30 insertions(+) delete mode 100644 images/009_deck_splash.png diff --git a/ACTION-PLAN.md b/ACTION-PLAN.md index a3e7c42..45b99ce 100644 --- a/ACTION-PLAN.md +++ b/ACTION-PLAN.md @@ -128,3 +128,33 @@ 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. diff --git a/images/009_deck_splash.png b/images/009_deck_splash.png deleted file mode 100644 index aceafc304442cbb3a2aa74c436a8a07463da4f97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12676 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 From e157c445090dbc2da4902c75041bd8544d3dc632 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:53:10 -0400 Subject: [PATCH 2/7] fix: widen IP address card on status page (#16) Card was single-width at 17px monospace, which wraps mid-octet on a full IPv4 address (word-break:break-all). Matches the existing Hostname card's span-2 treatment for another field with unpredictable length. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 84bb7d3..4b3724c 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -648,8 +648,8 @@ def render_status(alert="", alert_cls="a-ok"):
Hostname
{host}
-
IP Address
-
{ip}
+
IP Address
+
{ip}
MAC
{mac}
Network
From f4959f468ae095b1eddfced6df7b0e3e2d765b31 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:53:10 -0400 Subject: [PATCH 3/7] fix: correct Dashboard kiosk flag from --kiosk to --kiosk-mode (#13) companion-dashboard's main.js checks process.argv.includes('--kiosk-mode'), not '--kiosk' -- the wrong flag meant kiosk mode (fullscreen + the auto-started :80 web server) never actually triggered. Confirmed against upstream source, not guessed. Co-Authored-By: Claude Sonnet 5 --- scripts/install-dashboard.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From bd5199c75ca8dc0a073ed03575cfbf892140f746 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:54:13 -0400 Subject: [PATCH 4/7] feat: add Dashboard remote-config link to web UI (#15) Depends on #13's fix landing first -- companion-dashboard only auto-starts its /control web server (port 80) when real kiosk mode is detected. Link only shown while the kiosk service is actually active (dashboard_enabled() == svc_active), same gating as the existing Toggle Fullscreen button. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 4b3724c..4e7f1a0 100755 --- a/src/dpx-buttonode-ui/dpx-buttonode-ui.py +++ b/src/dpx-buttonode-ui/dpx-buttonode-ui.py @@ -837,6 +837,7 @@ def dashboard_section(): {'
' if on else ''} + {f'⚙ Remote Config ↗' if on else ''}
""" From 37e23ee3b15bad31420acfeeace5ca2a696a064f Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 11:56:11 -0400 Subject: [PATCH 5/7] fix: network settings not persisting on NetworkManager boards (#14) toggle_net()/pin_static() -- the functions the deck's NET key drives -- called write_networkd_config() unconditionally, writing systemd-networkd files and restarting systemd-networkd. On Raspberry Pi OS, which ships NetworkManager by default, that does nothing durable: NetworkManager keeps managing the interface and reasserts its own connection profile's DHCP setting on next boot, which is exactly the "has to be reselected every boot" behavior reported. The manual /network web form already branched on nmcli_available() vs networkd correctly -- the deck-driven functions never got the same treatment. Added write_nmcli_config() (nmcli connection modify, which persists straight to the on-disk profile) and an apply_net_config() dispatcher, and pointed toggle_net()/pin_static() at the dispatcher instead of the networkd-only function directly. get_current_net_mode() in dpx-deck-splash.py already reads live kernel state via `ip addr`, so it correctly reflects whichever backend actually applied the change -- no read-side fix needed, this was write-path only. Co-Authored-By: Claude Sonnet 5 --- src/dpx-buttonode-ui/dpx-buttonode-ui.py | 58 ++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/dpx-buttonode-ui/dpx-buttonode-ui.py b/src/dpx-buttonode-ui/dpx-buttonode-ui.py index 4e7f1a0..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}" From dcd21db1ad3a8ad37d718cf88361652c7d048c3d Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 12:01:12 -0400 Subject: [PATCH 6/7] feat: event-driven splash recovery + deterministic boot mode selection (#11, #12) Two related gaps, one mechanism, per the design decision to solve them together rather than as separate bolted-on fixes: - #11: nothing brought dpx-deck-splash.service back once a mode service's own Restart=on-failure exhausted its StartLimitBurst -- the deck just went dark/stale forever. Fixed with OnFailure=dpx-deck-splash.service drop-ins on all three mode units (as .service.d/ overrides, not direct edits, since they ship from vendor .deb packages). OnFailure= only fires once a unit's ActiveState actually reaches "failed" -- systemd holds it in "activating (auto-restart)" between individual retries -- so this is inherently once-per-real-outage, not once-per-retry. Event-driven, no polling. - #12: dpx-deck-splash.service and the persisted mode service were both WantedBy=multi-user.target, racing at boot with Conflicts= picking whichever won -- confirmed nondeterministic on hardware ("sometimes splash wins and blocks it without a GO press"). Fixed with a new dpx-mode-select.service oneshot that reads /etc/dpx-mode at boot and starts exactly that one service, falling back to the splash only if nothing's persisted or the target refuses to start. Splash is no longer auto-enabled on its own, so there's nothing left to race. Not live-verified yet -- no device access this pass. Needs a real boot-cycle test and a forced-permanent-failure test once hardware is available; also worth rechecking the "GO does nothing" symptom against this fix, since execute_staged()'s existing mode_dead check already looks correct on paper and may have been a downstream effect of the same race rather than its own bug. Co-Authored-By: Claude Sonnet 5 --- ACTION-PLAN.md | 7 ++++ scripts/install-deck-splash.sh | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/ACTION-PLAN.md b/ACTION-PLAN.md index 45b99ce..6140f5a 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()`, 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 ───────────────────────────────────────── From 7e19e232ee1945e98006e45ec918ff29fa3d7488 Mon Sep 17 00:00:00 2001 From: Joshua B Fleitell Date: Sat, 5 Sep 2026 12:02:07 -0400 Subject: [PATCH 7/7] ci: auto-delete redundant + expired Actions artifacts (#18) Two layers: release-action.yaml's release job now deletes its own run's CI artifacts right after they land in the GitHub Release (they're redundant once the .img.gz is a real release asset). New weekly artifact-sweep.yaml workflow catches everything else -- stray feature- branch/debug builds that never go through the release job -- by deleting anything already past its own expires_at, since GitHub's own cleanup can lag by weeks in practice (confirmed this session: found and manually cleared 12.1GiB that was up to three weeks overdue). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/artifact-sweep.yaml | 35 +++++++++++++++++++++++++++ .github/workflows/release-action.yaml | 18 ++++++++++++++ ACTION-PLAN.md | 8 ++++++ 3 files changed, 61 insertions(+) create mode 100644 .github/workflows/artifact-sweep.yaml 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 6140f5a..636b2a0 100644 --- a/ACTION-PLAN.md +++ b/ACTION-PLAN.md @@ -165,3 +165,11 @@ notices. 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.