Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/artifact-sweep.yaml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions .github/workflows/release-action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
actions: write

steps:
- name: Checkout repository
Expand Down Expand Up @@ -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
45 changes: 45 additions & 0 deletions ACTION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<mode>.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()`,
Expand All @@ -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/<id>`, 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.
Binary file removed images/009_deck_splash.png
Binary file not shown.
2 changes: 1 addition & 1 deletion scripts/install-dashboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
70 changes: 68 additions & 2 deletions scripts/install-deck-splash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────
Expand Down
63 changes: 58 additions & 5 deletions src/dpx-buttonode-ui/dpx-buttonode-ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"


Expand Down Expand Up @@ -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}"


Expand Down Expand Up @@ -648,8 +700,8 @@ def render_status(alert="", alert_cls="a-ok"):
<div class="grid">
<div class="card" style="grid-column:span 2"><div class="lbl">Hostname</div>
<div class="val" style="font-size:15px">{host}</div></div>
<div class="card"><div class="lbl">IP Address</div>
<div class="val">{ip}</div></div>
<div class="card" style="grid-column:span 2"><div class="lbl">IP Address</div>
<div class="val" style="font-size:15px">{ip}</div></div>
<div class="card"><div class="lbl">MAC</div>
<div class="val" style="font-size:12px">{mac}</div></div>
<div class="card"><div class="lbl">Network</div>
Expand Down Expand Up @@ -837,6 +889,7 @@ def dashboard_section():
<button type="submit" class="btn {'btn-w' if on else 'btn-p'}">{label}</button>
</form>
{'<form method="POST" action="/dashboard/fullscreen" style="display:inline"><button type="submit" class="btn">⛶ Toggle Fullscreen</button></form>' if on else ''}
{f'<a href="http://{esc(get_ip())}/control" target="_blank" class="btn" style="text-decoration:none;display:inline-block">⚙ Remote Config ↗</a>' if on else ''}
</div>"""


Expand Down
Loading