chore: release 26.8-alpha (power actions, supersedes orphan 26.7 tag)
Some checks failed
Build ISO / build-iso (push) Successful in 26m56s
Deploy site / deploy (push) Successful in 23s
CI / lint (push) Successful in 34s
CI / test (push) Successful in 1m4s
CI / validate-json (push) Successful in 51s
CI / markdown-links (push) Successful in 28s
Release / release (push) Failing after 7m38s
Some checks failed
Build ISO / build-iso (push) Successful in 26m56s
Deploy site / deploy (push) Successful in 23s
CI / lint (push) Successful in 34s
CI / test (push) Successful in 1m4s
CI / validate-json (push) Successful in 51s
CI / markdown-links (push) Successful in 28s
Release / release (push) Failing after 7m38s
Adds Reboot + Shut down buttons on /settings, backed by a new
POST /api/furtka/power endpoint that kicks a delayed `systemd-run
--on-active=3s systemctl {reboot|poweroff}` so the HTTP response
flushes before the kernel loses network. Both buttons open a native
confirm dialog; after reboot, the page polls /furtka.json until the
box is back and reloads itself.
26.7-alpha was tagged on 5d8ac63 but release.yml never fired for that
tag (Forgejo race with the concurrent main push; re-push of the deleted
tag didn't wake the workflow either). 26.8 supersedes it and carries
the same open_url + Open-button content plus the power actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d8ac63d9f
commit
cf93ef44cb
12 changed files with 273 additions and 17 deletions
|
|
@ -1,27 +1,58 @@
|
|||
name: Release
|
||||
|
||||
# Tag-triggered: when `git push origin <version>` lands, this builds the
|
||||
# release tarball and publishes it + the sha256 + release.json to the
|
||||
# Forgejo releases page for that tag. Boxes then POST /api/furtka/update
|
||||
# to pull from here.
|
||||
# release tarball + the live-installer ISO, and publishes them both to
|
||||
# the Forgejo releases page. Boxes POST /api/furtka/update to pull the
|
||||
# tarball; fresh-install users download the ISO from the release page.
|
||||
#
|
||||
# Version tags only (pattern matches CalVer like 26.0-alpha, 26.1, 27.0-beta).
|
||||
# Documentation / random tags are ignored by the [0-9]* prefix.
|
||||
# Runs on the self-hosted runner because iso/build.sh needs privileged
|
||||
# docker access (mkarchiso wants root + loop mounts), and because the
|
||||
# ubuntu-latest Forgejo hosted runner doesn't carry the docker socket
|
||||
# bind-mount the build needs. Self-hosted adds ~5-7 min to the release
|
||||
# (ISO build) but keeps the release page self-contained.
|
||||
#
|
||||
# Version tags only (CalVer like 26.0-alpha, 26.1, 27.0-beta). Random
|
||||
# tags are ignored by the [0-9]* prefix.
|
||||
on:
|
||||
push:
|
||||
tags: ['[0-9]*']
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # changelog section extraction needs history
|
||||
|
||||
- name: Install prerequisites
|
||||
# Alpine runner is near-empty: we need curl + python3 for the
|
||||
# publish script, bash for the build scripts.
|
||||
run: apk add --no-cache curl python3 bash
|
||||
|
||||
- name: Build release tarball
|
||||
run: ./scripts/build-release-tarball.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Build live-installer ISO
|
||||
# Same script build-iso.yml uses on every main push. Re-running
|
||||
# here is intentional: guarantees the ISO matches the exact
|
||||
# tagged commit without coordinating across workflows. Step-level
|
||||
# continue-on-error so an ISO build flake doesn't block the
|
||||
# core tarball (which is what boxes need for self-update) from
|
||||
# publishing.
|
||||
continue-on-error: true
|
||||
id: build_iso
|
||||
run: ./iso/build.sh
|
||||
|
||||
- name: Move ISO into dist/
|
||||
# publish-release.sh attaches dist/furtka-<ver>.iso if present.
|
||||
# Skipped gracefully when the build step above failed.
|
||||
if: steps.build_iso.outcome == 'success'
|
||||
run: |
|
||||
iso=$(ls iso/out/*.iso | head -1)
|
||||
cp "$iso" "dist/furtka-${GITHUB_REF_NAME}.iso"
|
||||
|
||||
- name: Publish to Forgejo releases
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -7,16 +7,22 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.7-alpha] - 2026-04-20
|
||||
## [26.8-alpha] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Live-installer ISO attached to the Forgejo release page.** `.forgejo/workflows/release.yml` moves to the self-hosted runner, builds both the self-update tarball and the ISO, and `scripts/publish-release.sh` uploads the ISO as a fourth release asset (`furtka-<version>.iso`) alongside the existing tarball + sha256 + release.json. Fresh-install users can now grab the ISO from the release page instead of hunting through `build-iso.yml` artifact retention windows. ISO build step is `continue-on-error` so an ISO flake doesn't hold back the core tarball that running boxes need for self-update.
|
||||
- **Reboot + Shut down buttons on `/settings`.** Replaces the two "Coming next" placeholders with real actions backed by `POST /api/furtka/power` (`{"action": "reboot" | "poweroff"}`). Handler kicks a delayed `systemd-run --on-active=3s systemctl {reboot|poweroff}` so the HTTP response reaches the browser before the kernel loses network. Each button opens a native confirm dialog first (reboot: "back in ~30 s", shut down: "need to press the physical power button"), then the UI swaps to a status line and — after a reboot — polls `/furtka.json` until the box is back, reloading the page automatically. No auth (same posture as install/remove).
|
||||
- **Manifest `open_url` field + Open button in `/apps` and on the landing page.** Apps declare a URL template (e.g. `smb://{host}/files` for fileshare, `http://{host}:3001/` for Uptime Kuma); the UI substitutes `{host}` with the current browser's hostname at render time so the link follows however the user reached Furtka (furtka.local, raw IP, a future reverse-proxy hostname). The landing page's hardcoded `if app.name === 'fileshare'` special-case is gone — any app with an `open_url` in its manifest now gets a proper "Open" link. The core seed `apps/fileshare/manifest.json` bumps to v0.1.2 to carry it.
|
||||
|
||||
### Changed
|
||||
|
||||
- `.btn` CSS class introduced so an `<a>` rendered-as-button lines up with its `<button>` siblings in `.buttons`. Needed because "Open" is a real link (middle-click, copy URL, screen readers) and HTML doesn't let `<button>` carry `href`.
|
||||
|
||||
### Notes
|
||||
|
||||
- `26.7-alpha` was tagged but never published — the tag push didn't trigger `release.yml` (Forgejo race with the concurrent main push). `26.8-alpha` supersedes it and carries the same content plus power actions.
|
||||
|
||||
## [26.6-alpha] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
|
@ -124,8 +130,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
|
|||
- **Containers:** Docker + Compose
|
||||
- **License:** AGPL-3.0
|
||||
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.7-alpha...HEAD
|
||||
[26.7-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.7-alpha
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.8-alpha...HEAD
|
||||
[26.8-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.8-alpha
|
||||
[26.6-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.6-alpha
|
||||
[26.5-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.5-alpha
|
||||
[26.4-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.4-alpha
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ Tag per meaningful milestone, not on a calendar. A milestone is: ISO boots, a wi
|
|||
git push origin 26.1-alpha
|
||||
```
|
||||
|
||||
5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `scripts/publish-release.sh` uploads all three assets to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`).
|
||||
5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push and runs on the self-hosted runner: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `iso/build.sh` builds the live-installer ISO, `scripts/publish-release.sh` uploads tarball + sha256 + release.json + ISO to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`). ISO build is `continue-on-error`: a flaky ISO step doesn't block the core tarball (the thing boxes need for self-update).
|
||||
|
||||
The release workflow needs one secret set at repo **Settings → Secrets → Actions**:
|
||||
- `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope.
|
||||
|
|
|
|||
|
|
@ -89,12 +89,25 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Power</h2>
|
||||
<div class="card">
|
||||
<p class="lede">
|
||||
Reboot or shut down the whole Furtka box. Takes a few seconds to
|
||||
finish; the UI will reconnect itself after a reboot.
|
||||
</p>
|
||||
<div class="power-actions">
|
||||
<button type="button" id="power-reboot" class="secondary">Reboot</button>
|
||||
<button type="button" id="power-poweroff" class="danger">Shut down</button>
|
||||
</div>
|
||||
<p id="power-status" class="hint"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Coming next</h2>
|
||||
<div class="coming">
|
||||
<p class="hint">Controls we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
|
||||
<a href="https://furtka.org/#planned">Reboot</a>
|
||||
<a href="https://furtka.org/#planned">Shut down</a>
|
||||
<a href="https://furtka.org/#planned">Change hostname</a>
|
||||
<a href="https://furtka.org/#planned">Backup</a>
|
||||
<a href="https://furtka.org/#planned">User accounts</a>
|
||||
|
|
@ -340,6 +353,85 @@
|
|||
/* keep polling; restart blip expected */
|
||||
}
|
||||
}
|
||||
|
||||
// Power buttons: confirm, POST, then swap the whole card into a
|
||||
// "going down" state so the user doesn't keep clicking. After a
|
||||
// reboot we try to reconnect after ~45s; for shutdown we just
|
||||
// tell the user the box is off — no auto-reconnect attempt.
|
||||
const powerStatusEl = document.getElementById('power-status');
|
||||
const rebootBtn = document.getElementById('power-reboot');
|
||||
const poweroffBtn = document.getElementById('power-poweroff');
|
||||
|
||||
function setPowerStatus(msg, tone = 'muted') {
|
||||
powerStatusEl.textContent = msg;
|
||||
powerStatusEl.style.color =
|
||||
tone === 'error' ? 'var(--danger)' : 'var(--muted)';
|
||||
}
|
||||
|
||||
async function triggerPower(action, confirmMsg, inflightLabel) {
|
||||
if (!confirm(confirmMsg)) return;
|
||||
rebootBtn.disabled = true;
|
||||
poweroffBtn.disabled = true;
|
||||
setPowerStatus(inflightLabel);
|
||||
try {
|
||||
const r = await fetch('/api/furtka/power', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
setPowerStatus(data.error || `HTTP ${r.status}`, 'error');
|
||||
rebootBtn.disabled = false;
|
||||
poweroffBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === 'reboot') {
|
||||
setPowerStatus('Rebooting… this page will reload when the box is back.');
|
||||
// Try reconnecting after a generous delay. archinstall
|
||||
// + boot + services typically takes 30–45 s; give it 30
|
||||
// before the first poke so we don't just spin against
|
||||
// a down kernel.
|
||||
setTimeout(pollForReconnect, 30000);
|
||||
} else {
|
||||
setPowerStatus(
|
||||
'Shutdown scheduled. Press the physical power button to turn it back on.'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setPowerStatus(`Network error: ${e.message}`, 'error');
|
||||
rebootBtn.disabled = false;
|
||||
poweroffBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollForReconnect() {
|
||||
// Fetch a tiny static file; when it comes back 200 the box is up.
|
||||
try {
|
||||
const r = await fetch('/furtka.json', { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
setPowerStatus('Back up — reloading…');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* still down */ }
|
||||
setTimeout(pollForReconnect, 3000);
|
||||
}
|
||||
|
||||
rebootBtn.addEventListener('click', () =>
|
||||
triggerPower(
|
||||
'reboot',
|
||||
"Wirklich neu starten? Die Box ist für ~30 Sekunden nicht erreichbar.",
|
||||
'Rebooting…'
|
||||
)
|
||||
);
|
||||
poweroffBtn.addEventListener('click', () =>
|
||||
triggerPower(
|
||||
'poweroff',
|
||||
"Wirklich ausschalten? Du kannst die Box erst wieder starten, wenn du den physischen Power-Knopf drückst.",
|
||||
'Shutting down…'
|
||||
)
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -329,7 +329,8 @@ details.log-details[open] > summary { color: var(--fg); }
|
|||
|
||||
/* Row of buttons beneath a card — used by the Furtka updates card on
|
||||
/settings. Left-aligned, wraps on narrow screens. */
|
||||
.update-actions {
|
||||
.update-actions,
|
||||
.power-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -727,6 +727,55 @@ def _do_catalog_status():
|
|||
}
|
||||
|
||||
|
||||
_POWER_ACTIONS = {
|
||||
"reboot": "reboot",
|
||||
"poweroff": "poweroff",
|
||||
}
|
||||
|
||||
|
||||
def _do_power(payload):
|
||||
"""Schedule a reboot or poweroff with a short delay.
|
||||
|
||||
`systemd-run --on-active=3s` kicks a transient timer that fires
|
||||
`systemctl {reboot|poweroff}` a few seconds after the API returns —
|
||||
long enough for the HTTP response to reach the browser + the UI to
|
||||
swap to a "Going down…" state before the kernel loses network.
|
||||
The `--no-block` flag makes the systemd-run call itself return
|
||||
immediately; `--collect` GCs the transient unit once it fires.
|
||||
|
||||
No auth: same posture as the install/remove endpoints. Anyone on the
|
||||
LAN can reboot the box. The /settings banner warns about this;
|
||||
Authentik will lock it down.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
action = payload.get("action")
|
||||
systemctl_verb = _POWER_ACTIONS.get(action)
|
||||
if systemctl_verb is None:
|
||||
return 400, {"error": f"'action' must be one of {sorted(_POWER_ACTIONS)}"}
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"systemd-run",
|
||||
"--on-active=3s",
|
||||
"--no-block",
|
||||
"--collect",
|
||||
"systemctl",
|
||||
systemctl_verb,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return 502, {"error": "systemd-run not available"}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return 502, {
|
||||
"error": f"systemd-run failed: {(e.stderr or e.stdout or '').strip()}",
|
||||
}
|
||||
return 202, {"action": action, "scheduled_in_seconds": 3}
|
||||
|
||||
|
||||
def _do_update(name):
|
||||
"""Pull newer container images for an installed app; restart if any changed.
|
||||
|
||||
|
|
@ -866,6 +915,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
status, body = _do_catalog_apply()
|
||||
return self._json(status, body)
|
||||
|
||||
# System power: /settings Reboot / Shut down buttons.
|
||||
if self.path == "/api/furtka/power":
|
||||
status, body = _do_power(payload)
|
||||
return self._json(status, body)
|
||||
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
return self._json(400, {"error": "missing or empty 'name' field"})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "furtka"
|
||||
version = "26.7-alpha"
|
||||
version = "26.8-alpha"
|
||||
description = "Open-source home server OS — simple enough for everyone."
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -99,4 +99,13 @@ upload_asset "$TARBALL"
|
|||
upload_asset "$SHA_FILE"
|
||||
upload_asset "$RELEASE_JSON"
|
||||
|
||||
# Optional: attach the live-installer ISO when dist/furtka-<version>.iso
|
||||
# exists. Release workflows that want this build the ISO via iso/build.sh
|
||||
# and move the output here before calling publish-release. Local runs
|
||||
# that skip the ISO step still publish the core release successfully.
|
||||
ISO="$DIST_DIR/furtka-$VERSION.iso"
|
||||
if [ -f "$ISO" ]; then
|
||||
upload_asset "$ISO"
|
||||
fi
|
||||
|
||||
echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"
|
||||
|
|
|
|||
|
|
@ -639,3 +639,66 @@ def test_catalog_check_surfaces_forgejo_error(fake_dirs, monkeypatch):
|
|||
status, body = api._do_catalog_check()
|
||||
assert status == 502
|
||||
assert "forgejo api down" in body["error"]
|
||||
|
||||
|
||||
# --- Power endpoints --------------------------------------------------------
|
||||
|
||||
|
||||
def test_power_rejects_unknown_action(fake_dirs):
|
||||
status, body = api._do_power({"action": "format-harddrive"})
|
||||
assert status == 400
|
||||
assert "action" in body["error"]
|
||||
|
||||
|
||||
def test_power_rejects_missing_action(fake_dirs):
|
||||
status, body = api._do_power({})
|
||||
assert status == 400
|
||||
|
||||
|
||||
def test_power_reboot_dispatches_systemd_run(fake_dirs, monkeypatch):
|
||||
seen = []
|
||||
|
||||
class _FakeCompleted:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
def fake_run(cmd, *, check=False, capture_output=False, text=False):
|
||||
seen.append(cmd)
|
||||
return _FakeCompleted()
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
status, body = api._do_power({"action": "reboot"})
|
||||
assert status == 202
|
||||
assert body == {"action": "reboot", "scheduled_in_seconds": 3}
|
||||
# The dispatched command is a delayed systemd-run that eventually
|
||||
# invokes `systemctl reboot`. Asserting the key flags catches
|
||||
# accidental regressions (e.g. losing --no-block would block the API
|
||||
# thread until the unit completes).
|
||||
assert seen[0][:1] == ["systemd-run"]
|
||||
assert "--on-active=3s" in seen[0]
|
||||
assert "--no-block" in seen[0]
|
||||
assert seen[0][-2:] == ["systemctl", "reboot"]
|
||||
|
||||
|
||||
def test_power_poweroff_dispatches_systemctl_poweroff(fake_dirs, monkeypatch):
|
||||
seen = []
|
||||
|
||||
class _FakeCompleted:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda cmd, **kw: (seen.append(cmd), _FakeCompleted())[1])
|
||||
status, body = api._do_power({"action": "poweroff"})
|
||||
assert status == 202
|
||||
assert body["action"] == "poweroff"
|
||||
assert seen[0][-2:] == ["systemctl", "poweroff"]
|
||||
|
||||
|
||||
def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
|
||||
def boom(*a, **kw):
|
||||
raise FileNotFoundError(2, "No such file", "systemd-run")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", boom)
|
||||
status, body = api._do_power({"action": "reboot"})
|
||||
assert status == 502
|
||||
assert "systemd-run" in body["error"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: "Furtka"
|
||||
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
|
||||
status: "<span class=\"mono\">26.7-alpha</span> — in Arbeit"
|
||||
status: "<span class=\"mono\">26.8-alpha</span> — in Arbeit"
|
||||
---
|
||||
|
||||
**Furtka** ist ein offenes Heimserver-Betriebssystem.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: "Furtka"
|
||||
description: "Open-source home server OS — simple enough for everyone."
|
||||
status: "<span class=\"mono\">26.7-alpha</span> — work in progress"
|
||||
status: "<span class=\"mono\">26.8-alpha</span> — work in progress"
|
||||
---
|
||||
|
||||
**Furtka** is an open-source home server OS.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ enableRobotsTXT = true
|
|||
|
||||
[params]
|
||||
description = "Open-source home server OS — simple enough for everyone."
|
||||
version = "26.7-alpha"
|
||||
version = "26.8-alpha"
|
||||
contactEmail = "hallo@furtka.org"
|
||||
|
||||
[markup.goldmark.renderer]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue