feat(host/deck): one-click source rebuild — the update.sh run as a transient user unit
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m47s
deb / build-publish-host (push) Successful in 5m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
android / android (push) Successful in 7m2s
docker / builders-arm64cross (push) Successful in 11s
arch / build-publish (push) Failing after 7m33s
ci / rust (push) Successful in 7m35s
docker / deploy-docs (push) Successful in 39s
windows-host / package (push) Failing after 11m1s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m32s

The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install
gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs
under systemd-run --user so the script's own restart of punktfunk-host can't kill it
mid-build (a child in our cgroup would die with us). Outcome without version equality —
which a source rebuild can't promise: a failed build leaves the host alive to report it
(unit watched to failure, log attached); a successful one restarts us, and the new
source_build intent flag makes intent-present-at-boot itself the success signal (the
script only restarts the host after a successful install). The console stops treating
a live long build as a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:28:40 +02:00
co-authored by Claude Fable 5
parent 51d5f6cb29
commit 4a540bddc8
7 changed files with 165 additions and 4 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.22.2"
"version": "0.22.3"
},
"paths": {
"/api/v1/clients": {
+13 -2
View File
@@ -101,6 +101,9 @@ pub(crate) fn apply_support() -> &'static str {
{
"full"
}
// The Deck source rebuild is user-owned — no helper, no group.
#[cfg(target_os = "linux")]
detect::InstallKind::SteamosSource => "full",
_ => "notify",
}
}
@@ -421,12 +424,15 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply
| detect::InstallKind::Sysext
| detect::InstallKind::RpmOstree
| detect::InstallKind::Pacman
| detect::InstallKind::SteamosSource
);
if !windows_leg && !linux_leg {
return Err(ApplyError::Unsupported);
}
#[cfg(target_os = "linux")]
if linux_leg {
if linux_leg && kind != detect::InstallKind::SteamosSource {
// The Deck source rebuild is user-owned and needs no root helper; every other Linux
// leg goes through it.
if !linux::helper_installed() {
return Err(ApplyError::Unsupported);
}
@@ -519,7 +525,12 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply
#[cfg(target_os = "linux")]
{
let _ = &asset; // the Linux legs resolve through the package manager
linux::run_apply(&target_version, serial, &stage).map(|()| {
let run = if detect::detect().0 == detect::InstallKind::SteamosSource {
linux::run_apply_steamos(&target_version, serial, &stage)
} else {
linux::run_apply(&target_version, serial, &stage)
};
run.map(|()| {
// Staged / nothing-to-do wrote a durable result and this process lives
// on; an in-place change wrote the intent and our restart is queued.
// Either way the in-process job is finished.
+37
View File
@@ -35,6 +35,13 @@ pub(crate) struct IntentRecord {
pub installer_sha256: String,
/// Where the installer was told to log (`/LOG=`).
pub log_path: String,
/// A source rebuild (Steam Deck `update.sh`), where version equality proves nothing —
/// the workspace version only moves on bumps. The flow's own ordering carries the
/// proof instead: the script restarts the host ONLY after a successful build+install,
/// and its failure path is reported live (the host survives a failed build). So an
/// intent with this flag still present at boot ⟹ the rebuild succeeded.
#[serde(default)]
pub source_build: bool,
}
/// The durable outcome of the most recent apply attempt.
@@ -124,6 +131,20 @@ pub(crate) fn reconcile(
let Some(intent) = intent else {
return Reconciled::None;
};
if intent.source_build {
// See `IntentRecord::source_build`: presence at boot IS the success signal; the
// running version is the truthful "to" (a rebuild can legitimately keep it).
return Reconciled::Success(ResultRecord {
ok: true,
from: intent.from,
to: current_version.to_string(),
finished_unix: now_unix,
stage: None,
error: None,
log_path: Some(intent.log_path),
staged: false,
});
}
if current_version == intent.to {
return Reconciled::Success(ResultRecord {
ok: true,
@@ -167,6 +188,22 @@ mod tests {
started_unix: started,
installer_sha256: "ab".repeat(32),
log_path: "/logs/update-0.23.200.log".into(),
source_build: false,
}
}
#[test]
fn source_build_intent_is_success_with_the_running_version() {
let mut i = intent(0);
i.source_build = true;
// Same version as `from` (a rebuild without a bump) — still success, and `to` is
// what actually runs, not the manifest's label.
match reconcile(Some(i), "0.23.100", 10_000_000) {
Reconciled::Success(r) => {
assert!(r.ok);
assert_eq!(r.to, "0.23.100");
}
other => panic!("expected success, got {other:?}"),
}
}
+105
View File
@@ -99,6 +99,110 @@ pub(super) fn opt_in_hint() -> String {
.to_string()
}
/// The Steam Deck source-rebuild leg (plan U3.1): run `~/punktfunk/scripts/steamdeck/update.sh
/// --pull` in a TRANSIENT user unit — `systemd-run`, because the script ends by restarting
/// `punktfunk-host.service`, and a child inside our own cgroup would be killed by that restart
/// mid-run. No root involved (the Deck install is user-owned). Outcome plumbing:
/// - build FAILS → the script exits without restarting us → the poll below sees the unit fail
/// and reports it live, log attached;
/// - build SUCCEEDS → the script restarts us → we die mid-poll; the `source_build` intent at
/// next boot IS the success signal (`jobs::reconcile`).
pub(super) fn run_apply_steamos(
target_version: &str,
serial: u64,
stage: &dyn Fn(&'static str),
) -> Result<(), (&'static str, String)> {
let home = std::env::var("HOME").map_err(|_| ("applying", "no $HOME".to_string()))?;
let script = std::path::Path::new(&home).join("punktfunk/scripts/steamdeck/update.sh");
if !script.exists() {
return Err((
"applying",
format!(
"{} not found — is this the Deck on-device install?",
script.display()
),
));
}
let log = pf_paths::config_dir()
.join("logs")
.join("update-steamos.log");
if let Some(dir) = log.parent() {
let _ = std::fs::create_dir_all(dir);
}
jobs::write_json_atomic(
&jobs::intent_path(),
&IntentRecord {
from: env!("PUNKTFUNK_VERSION").into(),
to: target_version.into(),
serial,
started_unix: super::now_unix(),
installer_sha256: String::new(),
log_path: log.display().to_string(),
source_build: true,
},
)
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
const UNIT: &str = "pf-source-update";
// `--collect` reaps the transient unit even on failure, so a retry can reuse the name.
let launched = Command::new("systemd-run")
.args(["--user", "--collect", "--unit", UNIT, "bash", "-c"])
.arg(format!(
"exec >> '{}' 2>&1; exec bash '{}' --pull",
log.display(),
script.display()
))
.status();
match launched {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = std::fs::remove_file(jobs::intent_path());
return Err(("applying", format!("systemd-run exited {s}")));
}
Err(e) => {
let _ = std::fs::remove_file(jobs::intent_path());
return Err(("applying", format!("launch systemd-run: {e}")));
}
}
stage("applying");
// Follow the transient unit. A successful build restarts this process before the unit
// goes inactive, so leaving this loop alive means either "still building" or "failed".
let deadline = Instant::now() + Duration::from_secs(90 * 60);
loop {
std::thread::sleep(Duration::from_secs(5));
let state = capture(Command::new("systemctl").args(["--user", "is-active", UNIT]))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "failed".into());
match state.as_str() {
"active" | "activating" | "deactivating" | "reloading" => {
if Instant::now() > deadline {
// Leave the build running (killing a half-linked build helps nobody) but
// stop claiming it; the intent stays for reconcile if it ever finishes.
return Err((
"applying",
format!(
"the source rebuild is still running after 90 min — following it \
ends here; see {}",
log.display()
),
));
}
}
// The unit ended and we are STILL ALIVE ⇒ the script never reached its restart
// step ⇒ the build failed (an up-to-date tree still rebuilds+restarts).
_ => {
let _ = std::fs::remove_file(jobs::intent_path());
return Err((
"applying",
format!("the source rebuild failed — see {}", log.display()),
));
}
}
}
}
/// Run the whole Linux apply: start the oneshot, wait, interpret its result record, and for
/// an in-place binary change, write the intent and restart ourselves (boot reconciliation
/// reports the outcome). Blocking — run on a blocking thread.
@@ -244,6 +348,7 @@ pub(super) fn run_apply(
started_unix: super::now_unix(),
installer_sha256: String::new(),
log_path: "journalctl -u punktfunk-update.service".into(),
source_build: false,
},
)
.map_err(|e| ("restarting", format!("write intent record: {e}")))?;
@@ -95,6 +95,7 @@ pub(super) fn run_apply(
started_unix: super::now_unix(),
installer_sha256: asset.sha256.to_ascii_lowercase(),
log_path: log.display().to_string(),
source_build: false,
},
)
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
+5
View File
@@ -72,6 +72,11 @@ requires `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf`, because the
pacman update is a full `pacman -Syu` — partial upgrades are how Arch boxes break, and we
won't run one. After a successful update the host restarts itself and the page reconnects.
The **Steam Deck on-device build** gets the button too, with no opt-in (it's your own user's
install, no root involved): it runs the same `update.sh` rebuild the docs describe, which
compiles on the Deck — expect it to take a while; the card keeps showing progress and the log
lands in `~/.config/punktfunk/logs/update-steamos.log`.
## Turning the check off
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
+3 -1
View File
@@ -396,7 +396,9 @@ const ApplyProgress: FC<{
/>
</div>
)}
{timedOut && (
{/* A live job (e.g. the Deck's tens-of-minutes source rebuild) is not "timed out"
the warning is for the host being GONE longer than a restart explains. */}
{timedOut && !job && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
{m.update_apply_timeout()}
</p>