Compare commits

...
Author SHA1 Message Date
archipelago d4018a6e73 chore: prepare release v1.8.6-alpha 2026-08-31 15:34:51 -04:00
archipelago b57cba63d1 Merge remote-tracking branch 'gitea-vps2/main'
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:12:59 -04:00
archipelago 7bc9f69b1f fix(settings): start What's New history at v1.8.0 2026-08-31 15:12:18 -04:00
lfg2025 913743923c Merge pull request 'docs: deploy handoff — companion 0.5.28 to the live surfaces' (#150) from companion/0.5.28-deploy-handoff into main 2026-08-31 19:01:58 +00:00
Dorian 241e8cfca4 docs: handoff — deploy companion 0.5.28 (vc48) to the live surfaces
For the archi-dev-box agent: companion 0.5.28 is on main (PR #149) and
Gitea raw serves it (verified byte-identical, v1+v2+v3). Remaining: the
foundation server's static /packages mirror (the real-node QR download
URL — currently 0.5.27), node web-bundle redeploys (same as the
2026-07-23 flow), and confirming the demo stack flipped after CI's
webhook redeploy. Exact commands, expected shasum, and final verify
block included.
2026-08-31 20:01:44 +01:00
archipelago 017505c431 fix(release): include every curated changelog item 2026-08-31 14:52:47 -04:00
archipelago 7a39d8fbd1 fix(settings): sort What's New history newest-first
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 14:50:55 -04:00
archipelago e3275353b9 fix(release): publish assets before exposing manifest 2026-08-31 14:45:29 -04:00
lfg2025 9f1a289d1a Merge pull request 'Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch' (#149) from companion/0.5.28-ship into main
Demo images / Build & push demo images (push) Failing after 41s
2026-08-31 18:38:43 +00:00
Dorian dd07da53f9 chore(android): update companion apk download 2026-08-31 19:34:57 +01:00
Dorian 7d09418a09 Companion 0.5.28 — backup & restore (#128), NIP-46 remote signer (#139), companion-gated install pitch (#61 residual)
The companion-agent queue from the 2026-08-30 handoff, complete:

- Backup & Restore: hub sub-page, SAF export/import sealed in the
  node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
  with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
  nostrconnect:// QR pairing (scanner + OS deep link), per-request
  approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
  wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
  official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
  isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
  display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
  pure public-key function — verified against the fips crate).

Issues #61 (comment), #128, #139 closed on the tracker.
2026-08-31 19:34:05 +01:00
archipelago eef35d65b7 chore: release v1.8.5-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 14:27:00 -04:00
archipelago 3b3500a7dd test(image): gate installer crash-capture payload 2026-08-31 12:38:59 -04:00
archipelago 2f0f7fd388 fix(host): repair malformed legacy kdump defaults 2026-08-31 11:16:34 -04:00
archipelago b300a720db fix(host): query package allowlist without literal quotes 2026-08-31 10:44:15 -04:00
archipelago 5b6d278c46 fix(host): preserve shell variables in privileged fixups 2026-08-31 10:24:06 -04:00
archipelago 699669a5f7 fix(host): retain captured kdump vmcores 2026-08-31 09:57:09 -04:00
archipelago 54431fc856 fix(host): enforce the full kdump crash reservation 2026-08-31 09:16:18 -04:00
archipelago 3409db569e docs(release): complete the v1.7.44→current release-notes audit
Demo images / Build & push demo images (push) Failing after 37s
The RELEASE_NOTES_BACKLOG gate for cutting the next release, closed out:

- Eight sections backfilled to the curated standard, from the Settings
  What's New blocks, the old-lineage release commits, and the hotfix
  diffs: v1.7.44 (was four raw commit-hash lines), v1.7.47/48/64/65
  (thin), and v1.7.50/51/107 (real tagged releases whose sections were
  missing entirely — v1.7.107 restored verbatim from the curated copy
  at 35e9c624 that later went missing).
- Mechanical inventory across all 92 sections in range: every section
  now has ≥3 curated bullets, zero raw-hash entries.
- What's New modal regenerated for the three restored versions
  (sync-whats-new --check passes, 92 versions present).
- Manifest-notes-only confirmed by construction: the manifest reads its
  changelog from CHANGELOG.md and check-release-manifest.sh rejects raw
  or thin entries before publishing.

Evidence trail for the backfills is recorded in
docs/RELEASE_NOTES_BACKLOG.md.
2026-08-31 08:05:42 -04:00
archipelago cb71c25ea0 chore(catalog): carry the Cuprate store entry into the frontend public catalog
Demo images / Build & push demo images (push) Failing after 39s
generate-app-catalog.py only updates entries that already exist in each
catalog file, so the hand-curated cuprate entry (added to
app-catalog/catalog.json with 7b88ba59) never propagated to
neode-ui/public/catalog.json — the sync's field-bumps did, the new entry
did not. Both catalogs now carry identical 31-entry lists (verified
content-equal), so the browser-side store copy and the curated one agree.
2026-08-31 07:48:31 -04:00
archipelago c5eeb31055 fix(ui): wifi setup on a fresh install — reveal toggle + a no-network callout (#145)
Demo images / Build & push demo images (push) Failing after 39s
Two reports from a fresh install without a cable:

(a) No way to see the WiFi password being typed. Every password field in
    the app was a bare type=password input. PasswordRevealInput is the
    reusable fix — masked by default, one-tap eye toggle, v-model and
    enter pass-through — first applied to the WiFi prompt in ServerModals
    so a long key typed from across the room can be verified.

(b) WiFi settings are undiscoverable with no wired internet. New
    OnboardingNetworkCallout floats over every onboarding step when the
    node has NO physical link at all (no ethernet up, no WiFi associated
    — polled from network.list-interfaces, self-dismissing the moment a
    link exists) and deep-links 'Connect to WiFi' to
    /dashboard/server?open=wifi, which Server.vue consumes by popping the
    WiFi picker on arrival. Deliberately scoped the other way too:
    Archipelago is offline-first, so 'no internet' never nags — only 'no
    link at all', only during onboarding (the wrapper hosts /login too;
    the callout is restricted to /onboarding/* routes), and a failed probe
    stays silent. The query is consumed via history.replaceState so a
    KeepAlive tab-return never re-pops the modal, and Server.vue keeps
    reading it from the real URL rather than vue-router — its
    KeepAlive-mounted tests have no router context to give.

Verification: full frontend suite 1023/1023; type-check clean; production
build clean with both new strings confirmed in the emitted bundles
(OnboardingWrapper + Server chunks).
2026-08-31 07:47:47 -04:00
32 changed files with 1099 additions and 2000 deletions
+35 -5
View File
@@ -1,5 +1,13 @@
# Changelog
## v1.8.6-alpha (2026-08-31)
- **Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.
- **What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.
- **A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines.
## v1.8.5-alpha (2026-08-30)
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
@@ -309,6 +317,12 @@
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
## v1.7.107-alpha (2026-07-20)
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
- Your node rejoins the mesh faster after an update. Applying this update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. It now notices the restart and reconnects within seconds.
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle — two separate faults that had been failing the build.
## v1.7.106-alpha (2026-07-20)
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
@@ -688,11 +702,13 @@
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
- The gap this closes: apps launched through the orchestrator previously skipped the legacy start-time repair path entirely, so the same stale metadata the old flow cleaned up silently broke the new one. Both paths now converge on the same repairs.
## v1.7.64-alpha (2026-05-18)
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
- For operators mid-incident this changes the recovery loop: a failed apply can now be retried immediately from the System Update page instead of waiting out a throttle window while a node sits half-updated.
## v1.7.63-alpha (2026-05-18)
@@ -802,6 +818,18 @@
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
## v1.7.51-alpha (2026-04-30)
- Stack installs now adopt containers that already exist instead of failing on them — a repair or reinstall over leftover containers completes, and the adopted container's readiness is waited on like any fresh start.
- Failed installs come with evidence: the install path waits for its containers, and when one doesn't become healthy it captures that container's logs, so the error on screen names the real culprit instead of a bare timeout.
- Bitcoin RPC bindings are ensured as part of install, and the startup self-heal path gained additional ground for already-deployed nodes.
## v1.7.50-alpha (2026-04-30)
- The OTA bridge older nodes needed: deployed binaries only knew how to apply two artifacts (the backend binary and the frontend archive), so the scripts, app specs and docker assets newer releases carry never reached them. This release packs those payloads inside the frontend tarball — the one channel old binaries do apply — and the new backend promotes them into /opt once it starts.
- Runtime payloads are staged into timestamped directories and promoted atomically; a failed extraction cleans up its staging area instead of leaving half-written state for the next update to trip over.
- This is the release that un-sticks the fleet's update pipeline: from here on, an OTA can carry more than the two artifacts, and app installs on updated nodes use the specs that match their backend.
## v1.7.49-alpha (2026-04-30)
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
@@ -813,12 +841,15 @@
## v1.7.48-alpha (2026-04-29)
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where that runtime directory wasn't pre-created — the failure surfaced in systemd's mount-namespace setup before the service itself ever ran.
- ExecStartPre now creates /run/containers before the service starts, so the node's service manager finds the directory it needs on every boot; ISO installs from this version forward have the fix baked in.
- Existing nodes pick the fix up with a one-time `systemctl edit archipelago` adding the mkdir — after which the boot failure does not recur.
## v1.7.47-alpha (2026-04-29)
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
- The result: a fresh node's first hours are measurably shorter — initial block download and ElectrumX indexing were the two longest post-install waits, and both now run at the hardware's limit.
## v1.7.46-alpha (2026-04-29)
@@ -841,10 +872,9 @@
## v1.7.44-alpha (2026-04-28)
43de3b73 feat(orchestrator): complete container migration and release hardening
ce39430b feat(self-update): sync and rebuild UI containers on OTA
72dec5aa fix(lnd-ui): align container port across all specs
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
- Container orchestration migration completed, with release hardening across the app lifecycle — installs, updates and removals now run through one orchestrator path instead of the split legacy/Podman flows.
- OTA updates now rebuild and sync the app UI containers they carry, so an updated app serves the UI image that matches its backend instead of whatever happened to be on disk.
- LND UI port handling is aligned across all runtime specs, and release packaging moved to tarball-only payloads with the ISO build recipes archived — update payloads now carry only the files existing nodes need.
All notable changes to Archipelago will be documented in this file.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.8.6-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.4-alpha"
version = "1.8.6-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+171 -37
View File
@@ -41,7 +41,7 @@ use crate::update::host_sudo;
/// Packages the node's host must have. Keep this list short and justified —
/// every entry is state we now own on the fleet's OS images.
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "rasdaemon"];
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"];
/// Crash-kernel reservation. 256M covers the capture kernel plus makedumpfile
/// on the fleet's 16–64GB amd64 machines (~1–2% of RAM, permanently reserved).
@@ -131,11 +131,10 @@ async fn run_host_fixups() -> Result<()> {
/// install without `apt-get update` first; only if that fails (fresh suite,
/// stale index), update once and retry. Both under timeout, both non-fatal.
async fn ensure_packages() -> Result<bool> {
let wanted = HOST_PACKAGES
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(" ");
// Package names are a fixed internal allowlist. Do not embed shell quote
// characters in WANTED: quotes produced by variable expansion are data,
// so dpkg-query would look for a package literally named 'kdump-tools'.
let wanted = HOST_PACKAGES.join(" ");
let script = format!(
r#"
set -u
@@ -197,25 +196,32 @@ exit 2
/// Point kdump-tools at /var/crash with a compressed core collector. Works on
/// the package's shipped defaults file (USE_KDUMP=0, commented KDUMP_COREDIR)
/// and on any state we already wrote — pure line surgery, idempotent.
async fn ensure_kdump_defaults() -> Result<()> {
let script = r#"
fn kdump_defaults_script(conf: &str) -> String {
r#"
set -u
CONF=/etc/default/kdump-tools
CONF='@@CONF@@'
[ -f "$CONF" ] || exit 3
CHANGED=0
# Remove the one malformed line emitted by the old systemd-run environment
# expansion bug before it was disabled. It makes every kdump-config invocation
# print an error while sourcing this file.
if grep -Fqx '=""' "$CONF"; then
sed -i '/^=""$/d' "$CONF"
CHANGED=1
fi
set_kv() {
# set_kv KEY VALUE — replace any (possibly commented) KEY= line with
# KEY='VALUE', appending at the end when absent.
# Canonicalise KEY to one double-quoted assignment. Older fixup versions
# could append duplicates because their exact-value check did not accept
# double quotes; collapsing them also makes future passes idempotent.
KEY="$1"; VAL="$2"
if grep -qE "^${KEY}=" "$CONF" 2>/dev/null; then
if ! grep -qE "^${KEY}='?${VAL}'?$" "$CONF"; then
sed -i "s|^${KEY}=.*|${KEY}=\"${VAL}\"|" "$CONF"
CHANGED=1
fi
else
printf '\n%s="%s"\n' "$KEY" "$VAL" >> "$CONF"
CHANGED=1
EXPECTED="${KEY}=\"${VAL}\""
COUNT=$(grep -c "^${KEY}=" "$CONF" 2>/dev/null || true)
if [ "$COUNT" -eq 1 ] && grep -Fqx "$EXPECTED" "$CONF"; then
return
fi
sed -i "/^${KEY}=/d" "$CONF"
printf '\n%s\n' "$EXPECTED" >> "$CONF"
CHANGED=1
}
set_kv USE_KDUMP 1
set_kv KDUMP_COREDIR /var/crash
@@ -223,8 +229,13 @@ set_kv CORE_COLLECTOR 'makedumpfile -l --message-level 1 -d 31'
[ "$CHANGED" -eq 1 ] || exit 0
systemctl enable kdump-tools >/dev/null 2>&1 || true
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
"#
.replace("@@CONF@@", conf)
}
async fn ensure_kdump_defaults() -> Result<()> {
let script = kdump_defaults_script("/etc/default/kdump-tools");
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("configure kdump-tools")?;
match status.code() {
@@ -237,24 +248,39 @@ exit 2
}
}
/// Append `crashkernel=` to the installed GRUB cmdline and run update-grub.
/// Set the installed GRUB cmdline to one fixed `crashkernel=` reservation and
/// run update-grub. Debian's kdump-tools package installs a grub.d snippet that
/// otherwise appends its own range-based reservation after ours; on amd64 that
/// silently wins and reserves only 192M instead of the intended 256M.
/// The reservation itself only exists after the next reboot — memory cannot
/// be set aside at runtime — so the caller must log the reboot caveat.
/// Returns true if the cmdline changed.
/// Returns true if the generated cmdline changed.
async fn ensure_crashkernel_cmdline() -> Result<bool> {
let script = format!(
r#"
set -u
GRUB=/etc/default/grub
KDUMP_GRUB=/etc/default/grub.d/kdump-tools.cfg
PARAM='{CRASHKERNEL_PARAM}'
[ -f "$GRUB" ] || exit 3
CHANGED=0
# kdump-tools sources this after /etc/default/grub and unconditionally appends
# crashkernel=512M-:192M. Neutralize that package default: Archipelago owns the
# explicit fixed reservation in GRUB_CMDLINE_LINUX_DEFAULT below.
if [ -f "$KDUMP_GRUB" ] && grep -qE '^[^#]*crashkernel=' "$KDUMP_GRUB"; then
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' > "$KDUMP_GRUB"
CHANGED=1
fi
LINE=$(grep -E '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB" | head -1)
[ -n "$LINE" ] || exit 3
case "$LINE" in
*"$PARAM"*) exit 0 ;;
esac
NEWLINE=$(printf '%s' "$LINE" | sed "s/\"$/ $PARAM\"/")
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
# Remove any prior value before appending ours, so repeated fixups can never
# create conflicting parameters whose kernel precedence is easy to misread.
NEWLINE=$(printf '%s' "$LINE" | sed -E "s/[[:space:]]+crashkernel=[^ \"']+//g; s/\"$/ $PARAM\"/")
if [ "$NEWLINE" != "$LINE" ]; then
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
CHANGED=1
fi
[ "$CHANGED" -eq 1 ] || exit 0
timeout 120 update-grub >/dev/null 2>&1 || true
exit 2
"#
@@ -283,21 +309,30 @@ async fn ensure_rasdaemon_enabled() -> Result<()> {
/// Keep only the newest [`KEEP_DUMPS`] dumps in /var/crash. Called on every
/// fixup pass rather than by a timer: the pass runs at every startup, which is
/// exactly the cadence at which new dumps appear (a dump ends in a reboot).
async fn prune_crash_dumps() -> Result<()> {
let script = format!(
fn crash_dump_prune_script() -> String {
format!(
r#"
set -u
DIR=/var/crash
DIR=${{ARCHIPELAGO_CRASH_DIR:-/var/crash}}
[ -d "$DIR" ] || exit 0
KEEP={KEEP_DUMPS}
COUNT=$(ls -1 "$DIR" 2>/dev/null | wc -l)
# kdump-tools keeps its lock and kexec command files beside timestamped dump
# directories. Count and prune directories only: treating those bookkeeping
# files as dumps can delete the sole freshly captured vmcore on startup.
COUNT=$(find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf . | wc -c)
[ "$COUNT" -gt "$KEEP" ] || exit 0
ls -1dt "$DIR"/* 2>/dev/null | tail -n +"$((KEEP + 1))" | while IFS= read -r victim; do
rm -rf -- "$victim"
done
find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\0' \
| sort -zrn \
| tail -z -n +"$((KEEP + 1))" \
| cut -z -d ' ' -f 2- \
| xargs -0r rm -rf --
exit 2
"#
);
)
}
async fn prune_crash_dumps() -> Result<()> {
let script = crash_dump_prune_script();
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("prune /var/crash")?;
@@ -329,7 +364,10 @@ mod tests {
#[test]
fn package_list_is_exactly_the_kdump_rasdaemon_set() {
assert_eq!(HOST_PACKAGES, &["kdump-tools", "kexec-tools", "rasdaemon"]);
assert_eq!(
HOST_PACKAGES,
&["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"]
);
}
#[test]
@@ -341,4 +379,100 @@ mod tests {
fn keep_dumps_is_two() {
assert_eq!(KEEP_DUMPS, 2);
}
#[test]
fn kdump_defaults_repairs_old_malformed_line_and_is_idempotent() {
use std::{fs, process::Command};
let root = tempfile::tempdir().unwrap();
let conf = root.path().join("kdump-tools");
let bin = root.path().join("bin");
fs::create_dir(&bin).unwrap();
fs::write(bin.join("systemctl"), "#!/bin/sh\nexit 0\n").unwrap();
assert!(Command::new("chmod")
.args(["+x"])
.arg(bin.join("systemctl"))
.status()
.unwrap()
.success());
fs::write(
&conf,
"# package defaults\n=\"\"\nUSE_KDUMP=0\nUSE_KDUMP=\"1\"\n",
)
.unwrap();
let script = kdump_defaults_script(conf.to_str().unwrap());
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
let first = Command::new("sh")
.args(["-lc", &script])
.env("PATH", &path)
.status()
.unwrap();
assert_eq!(first.code(), Some(2));
let repaired = fs::read_to_string(&conf).unwrap();
assert!(!repaired.lines().any(|line| line == "=\"\""));
assert_eq!(repaired.matches("USE_KDUMP=").count(), 1);
assert!(repaired.contains("USE_KDUMP=\"1\""));
assert!(repaired.contains("KDUMP_COREDIR=\"/var/crash\""));
assert!(repaired.contains("CORE_COLLECTOR=\"makedumpfile -l --message-level 1 -d 31\""));
let second = Command::new("sh")
.args(["-lc", &script])
.env("PATH", path)
.status()
.unwrap();
assert!(second.success());
assert_eq!(fs::read_to_string(conf).unwrap(), repaired);
}
#[test]
fn crash_pruning_ignores_kdump_bookkeeping_files() {
use std::{fs, process::Command};
let root = tempfile::tempdir().unwrap();
let crash = root.path();
fs::write(crash.join("kdump_lock"), []).unwrap();
fs::write(crash.join("kexec_cmd"), "kexec -p").unwrap();
for (name, epoch) in [("old dump", "100"), ("middle", "200"), ("newest", "300")] {
let path = crash.join(name);
fs::create_dir(&path).unwrap();
fs::write(path.join("vmcore"), name).unwrap();
assert!(Command::new("touch")
.args(["-d", &format!("@{epoch}")])
.arg(&path)
.status()
.unwrap()
.success());
}
let status = Command::new("sh")
.args(["-lc", &crash_dump_prune_script()])
.env("ARCHIPELAGO_CRASH_DIR", crash)
.status()
.unwrap();
assert_eq!(status.code(), Some(2));
assert!(!crash.join("old dump").exists());
assert!(crash.join("middle").join("vmcore").exists());
assert!(crash.join("newest").join("vmcore").exists());
assert!(crash.join("kdump_lock").exists());
assert!(crash.join("kexec_cmd").exists());
}
#[test]
fn crash_pruning_does_nothing_when_only_bookkeeping_files_exist() {
use std::{fs, process::Command};
let root = tempfile::tempdir().unwrap();
for name in ["kdump_lock", "kexec_cmd", "another-marker"] {
fs::write(root.path().join(name), []).unwrap();
}
let status = Command::new("sh")
.args(["-lc", &crash_dump_prune_script()])
.env("ARCHIPELAGO_CRASH_DIR", root.path())
.status()
.unwrap();
assert!(status.success());
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 3);
}
}
+6
View File
@@ -1487,6 +1487,11 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
"--quiet",
"--collect",
"--pipe",
// Shell snippets passed as one argument must reach the child intact.
// systemd-run otherwise expands $VAR/${VAR} against the manager's
// environment before `sh -lc` can see them (and usually replaces them
// with empty strings).
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
@@ -1506,6 +1511,7 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
"--quiet",
"--collect",
"--pipe",
"--expand-environment=no",
"--",
];
full.extend_from_slice(args);
@@ -0,0 +1,74 @@
# HANDOFF — deploy companion 0.5.28 (vc48) to the live surfaces
**For: the agent on archi-dev-box.** Companion 0.5.28 shipped to `main`
today (PR #149, merge `9f1a289d` — backup & restore #128, NIP-46 remote
signer #139, companion-gated install pitch #61 residual, hub sub-pages).
The dev box verified everything it can reach; three live surfaces remain,
same shape as the 2026-07-23 deploy handoff
([`HANDOFF-2026-07-23-companion-apk-deploy.md`](HANDOFF-2026-07-23-companion-apk-deploy.md)).
## Already done and verified (do not redo)
- `neode-ui/public/packages/archipelago-companion.apk` on `main` is
**0.5.28 / versionCode 48**, clean build via `Android/ship-companion.sh`,
**v1+v2+v3 signatures verified**, meta json refreshed beside it.
- Gitea raw-on-main serves it byte-identical:
`shasum -a 256` = `fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca`
(2 bytes: 28,206,999… file size ≈ 28.2 MB).
- The foundation server's **raw-proxy** path already serves 0.5.28 (verified
via `https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json`).
- Demo CI (`demo-images.yml`) fired on the push and redeploys the stack via
the Portainer webhook — should flip on its own; confirm only.
- Signing key unchanged (cert SHA-256 `d622e07e…ec2664d`), so phones update
**in place** over any 0.5.27 install.
## 1. Foundation server static `/packages/` mirror — the real-node QR URL
`https://source.archipelago-foundation.org/packages/archipelago-companion.apk`
is a **static dir** on the release server (openresty; still 0.5.27,
last-modified 2026-08-17). This is the exact URL real nodes' companion QR
downloads (`DEFAULT_DOWNLOAD_URL` in `CompanionIntroOverlay.vue`) — it must
flip before the release is done.
```bash
# Find the webroot once:
grep -rl "packages" /etc/openresty /etc/nginx 2>/dev/null
find / -name archipelago-companion.apk -not -path '/proc/*' 2>/dev/null
# Mirror the exact bytes from Gitea raw-on-main (no rebuild, no re-sign):
cd <that webroot>
curl -fsS -o archipelago-companion.apk http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk
curl -fsS -o archipelago-companion.json http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.json
shasum -a 256 archipelago-companion.apk
# MUST print: fc786b46c704c5752f04fe603371365524c749734f17bd8858cf02fa2dbc34ca
```
## 2. Node web-bundle redeploys
Same as 2026-07-23: redeploy the web-ui bundle from current `main` to the
active nodes — web root `/opt/archipelago/web-ui/` (NOT a `neode-ui/`
subfolder), at minimum every node the user pairs against. The APK rides in
the bundle's `packages/` dir, so this is also what makes each node's own
served QR download 0.5.28.
## 3. Confirm the demo flipped
`curl -s http://146.59.87.168:2100/packages/archipelago-companion.json`
should read 0.5.28/48 once CI's Portainer webhook redeploy lands; trigger a
stack redeploy if it lags.
## Final verify (all three must show 0.5.28 / 48)
```bash
aapt2 dump badging <downloaded apk> | head -1 # versionCode='48' versionName='0.5.28-debug'
apksigner verify -v --min-sdk-version 21 <downloaded apk> | grep scheme # v1/v2/v3 true
curl -s https://source.archipelago-foundation.org/packages/archipelago-companion.json
curl -s http://146.59.87.168:2100/packages/archipelago-companion.json
```
Then the user's on-device end-to-end: scan the node's companion QR →
installs vc48 in place → hub → Backup & Restore / Remote Signer.
Testing notes for the new features live in the closed tracker issues
(#61/#128/#139) and `docs/companion-backup-restore.md` /
`docs/companion-nip46-remote-signer.md` (the signer's e2e harness:
`Android/tools/nip46-test-client.py`).
+23 -5
View File
@@ -1,11 +1,29 @@
# Release Notes Backlog
## Next Release Required Work
## Required Work — completed 2026-08-30, before the v1.8.5-alpha cut
- Backfill missing or thin historical release notes before cutting the next release.
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
- [x] Backfill missing or thin historical release notes before cutting the next release.
Eight sections backfilled, sourced from the Settings "What's New" blocks,
the old-lineage release commits, and the diffs of the self-contained
hotfix releases: **v1.7.44** (was raw commit-hash lines), **v1.7.47,
v1.7.48, v1.7.64, v1.7.65** (were thin), and **v1.7.50, v1.7.51,
v1.7.107** (sections were missing entirely — real releases with tags but
no changelog section; v1.7.107 was restored verbatim from the curated
version that existed at `35e9c624` and was later lost). The What's New
modal blocks for the three restored versions were generated by
`scripts/sync-whats-new.py`, which now passes with all 92 versions.
- [x] Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the
current release. Mechanical inventory of all 92 sections in range:
every section carries ≥3 curated bullets, zero raw commit-hash entries.
- [x] Replace raw commit-hash entries with user/operator-facing bullets
that explain behavior changes, operational impact, validation, and
known limitations. The only offender was v1.7.44 (four raw hash lines,
now curated).
- [x] Ensure `releases/manifest.json` changelog entries come from curated
`CHANGELOG.md` notes only. Satisfied by construction:
`create-release-manifest.sh` reads the changelog from `CHANGELOG.md`,
and `check-release-manifest.sh` rejects manifests with fewer than three
bullets or raw git-log lines before publishing.
## Release Note Policy
@@ -3746,6 +3746,13 @@ if [ -d "$BOOT_MEDIA/archipelago/plymouth-theme" ]; then
# Configure clean boot: splash, suppress kernel noise, hide cursor
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT=".*"/GRUB_CMDLINE_LINUX_DEFAULT="quiet splash loglevel=0 rd.systemd.show_status=false vt.global_cursor_default=0 acpi=force crashkernel=256M"/' \
/mnt/target/etc/default/grub 2>/dev/null || true
# kdump-tools ships a grub.d snippet that appends crashkernel=512M-:192M
# after this line. The later value silently wins on amd64, so neutralize
# the package default and keep Archipelago's explicit fixed reservation.
if [ -f /mnt/target/etc/default/grub.d/kdump-tools.cfg ]; then
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' \
> /mnt/target/etc/default/grub.d/kdump-tools.cfg
fi
echo " Installed Archipelago Plymouth theme on target"
fi
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.4-alpha",
"version": "1.8.6-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.4-alpha",
"version": "1.8.6-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.4-alpha",
"version": "1.8.6-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+12
View File
@@ -571,6 +571,18 @@
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
Binary file not shown.
@@ -1,4 +1,4 @@
{
"versionName": "0.5.27",
"versionCode": 47
"versionName": "0.5.28",
"versionCode": 48
}
@@ -0,0 +1,117 @@
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-40 w-[min(92vw,420px)] glass-card px-4 py-3 flex items-start gap-3 shadow-xl"
>
<svg class="w-5 h-5 text-white/60 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393C6.957 3.83 17.043 3.83 22.606 9.393" />
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">No network connection</p>
<p class="text-xs text-white/60 mt-0.5">This node has no cable or WiFi link yet. You can set up WiFi now — it also works without internet.</p>
<div class="flex gap-2 mt-2.5">
<button
class="px-3 py-1.5 glass-button rounded-lg text-xs font-medium"
@click="goToWifi"
>
Connect to WiFi
</button>
<button
class="px-3 py-1.5 text-xs text-white/50 hover:text-white transition-colors"
@click="dismissed = true"
>
Dismiss
</button>
</div>
</div>
<button class="text-white/40 hover:text-white transition-colors shrink-0" aria-label="Dismiss" @click="dismissed = true">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Transition>
</template>
<script lang="ts">
/** True when at least one physical interface is up (ethernet or WiFi).
* Exported for tests — the component only needs this one pure decision. */
export function hasPhysicalLink(interfaces: { type: string; state: string }[]): boolean {
return interfaces.some(
(iface) => (iface.type === 'ethernet' || iface.type === 'wifi') && iface.state === 'up',
)
}
</script>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
/**
* Onboarding-only "no network at all" callout (#145).
*
* A fresh install without a cable can leave a user stranded: the WiFi
* settings live in Server → Network and nothing points there. This floats
* over the onboarding steps whenever the node has NO physical link (no
* ethernet up, no WiFi associated) and deep-links to the WiFi picker.
*
* Deliberately scoped the other way too: Archipelago is offline-first, so
* "no internet" must NEVER nag — only "no link at all" qualifies, and the
* callout is onboarding-context only (the wrapper renders it on
* /onboarding/* routes; logged-in users have their own places to look).
*/
const router = useRouter()
const dismissed = ref(false)
const hasLink = ref<boolean | null>(null)
const visible = computed(() => !dismissed.value && hasLink.value === false)
let timer: ReturnType<typeof setInterval> | null = null
let inFlight = false
async function check() {
if (inFlight) return
inFlight = true
try {
const res = await rpcClient.call<{ interfaces: { type: string; state: string }[] }>({
method: 'network.list-interfaces',
dedup: true,
maxRetries: 1,
})
hasLink.value = hasPhysicalLink(res?.interfaces ?? [])
} catch {
// Node busy or RPC not ready during early onboarding — never nag on a
// failed probe; treat unknown as "don't show".
hasLink.value = null
} finally {
inFlight = false
}
}
function goToWifi() {
dismissed.value = true
// Server.vue consumes ?open=wifi by popping the WiFi picker on arrival.
router.push('/dashboard/server?open=wifi')
}
onMounted(() => {
check()
// A cable gets plugged in mid-onboarding; poll gently so the callout
// dismisses itself the moment a link exists.
timer = setInterval(check, 15_000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
@@ -0,0 +1,55 @@
<template>
<div class="relative">
<input
:type="revealed ? 'text' : 'password'"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:autocomplete="autocomplete"
class="w-full px-3 py-2 pr-10 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keyup.enter="$emit('enter')"
>
<button
type="button"
class="absolute inset-y-0 right-0 px-3 text-white/40 hover:text-white/80 transition-colors"
:aria-label="revealed ? 'Hide password' : 'Show password'"
:title="revealed ? 'Hide password' : 'Show password'"
@click="revealed = !revealed"
>
<!-- eye -->
<svg v-if="!revealed" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<!-- eye-off -->
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
/**
* Password input with a reveal toggle (#145). Introduced for the WiFi SSID
* password — a fresh-install user typing a long wifi key into a TV from
* across the room needs to see what they typed — and written reusable so
* other password fields can adopt it without re-deriving the eye icon.
*/
defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
autocomplete?: string
}>()
defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'enter'): void
}>()
const revealed = ref(false)
</script>
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import OnboardingNetworkCallout, { hasPhysicalLink } from '../OnboardingNetworkCallout.vue'
import { rpcClient } from '@/api/rpc-client'
// #145: a fresh install with no cable strands the user — the callout points
// at the WiFi picker, and ONLY when no physical link exists. Archipelago is
// offline-first, so "no internet" must never nag: only "no link at all".
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
const push = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ push }),
}))
const call = vi.mocked(rpcClient.call)
function mountCallout() {
return mount(OnboardingNetworkCallout)
}
afterEach(() => {
vi.clearAllMocks()
})
describe('hasPhysicalLink (pure decision)', () => {
it('no interfaces at all → no link', () => {
expect(hasPhysicalLink([])).toBe(false)
})
it('ethernet up → link', () => {
expect(hasPhysicalLink([{ type: 'ethernet', state: 'up' }])).toBe(true)
})
it('wifi up → link', () => {
expect(hasPhysicalLink([{ type: 'wifi', state: 'up' }])).toBe(true)
})
it('physical interface present but down → no link', () => {
expect(
hasPhysicalLink([
{ type: 'ethernet', state: 'down' },
{ type: 'wifi', state: 'down' },
]),
).toBe(false)
})
it('virtual interfaces that happen to be up do NOT count as a link', () => {
expect(
hasPhysicalLink([
{ type: 'bridge', state: 'up' },
{ type: 'loopback', state: 'up' },
]),
).toBe(false)
})
})
describe('OnboardingNetworkCallout (component)', () => {
beforeEach(() => {
call.mockReset()
})
it('shows when the node has no physical link, and offers the WiFi picker', async () => {
call.mockResolvedValue({
interfaces: [
{ type: 'ethernet', state: 'down' },
{ type: 'wifi', state: 'down' },
],
})
const wrapper = mountCallout()
await flushPromises()
expect(wrapper.text()).toContain('No network connection')
expect(wrapper.text()).toContain('Connect to WiFi')
await wrapper.findAll('button').find(b => b.text() === 'Connect to WiFi')!.trigger('click')
expect(push).toHaveBeenCalledWith('/dashboard/server?open=wifi')
})
it('stays hidden once any physical link exists — offline-first, no nagging', async () => {
call.mockResolvedValue({ interfaces: [{ type: 'ethernet', state: 'up' }] })
const wrapper = mountCallout()
await flushPromises()
expect(wrapper.find('div.fixed').exists()).toBe(false)
})
it('never shows on a failed probe — early onboarding, RPC not ready yet', async () => {
call.mockRejectedValue(new Error('not ready'))
const wrapper = mountCallout()
await flushPromises()
expect(wrapper.find('div.fixed').exists()).toBe(false)
})
it('hides when dismissed, even with no link', async () => {
call.mockResolvedValue({ interfaces: [{ type: 'wifi', state: 'down' }] })
const wrapper = mountCallout()
await flushPromises()
const dismiss = wrapper.findAll('button').find(b => b.text() === 'Dismiss')!
expect(dismiss).toBeDefined()
await dismiss.trigger('click')
expect(wrapper.find('div.fixed').exists()).toBe(false)
})
})
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PasswordRevealInput from '../PasswordRevealInput.vue'
// #145: the reveal toggle exists so a fresh-install user typing a WiFi key
// from across the room can see what they typed. The contract: masked by
// default, one tap reveals, v-model and enter behave like a plain input.
describe('PasswordRevealInput', () => {
it('masks by default and reveals on toggle', async () => {
const wrapper = mount(PasswordRevealInput, {
props: { modelValue: 'hunter2', placeholder: 'WiFi password' },
})
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('password')
await wrapper.find('button').trigger('click')
expect(input.attributes('type')).toBe('text')
await wrapper.find('button').trigger('click')
expect(input.attributes('type')).toBe('password')
})
it('syncs v-model through update:modelValue', async () => {
const wrapper = mount(PasswordRevealInput, { props: { modelValue: '' } })
await wrapper.find('input').setValue('s3cret')
const emitted = wrapper.emitted('update:modelValue') as string[][]
expect(emitted[emitted.length - 1]).toEqual(['s3cret'])
})
it('emits enter on Enter keyup — the WiFi modal submits from the keyboard', async () => {
const wrapper = mount(PasswordRevealInput, { props: { modelValue: 'pw' } })
await wrapper.find('input').trigger('keyup.enter')
expect(wrapper.emitted('enter')).toHaveLength(1)
})
it('passes placeholder and disabled through to the input', () => {
const wrapper = mount(PasswordRevealInput, {
props: { modelValue: '', placeholder: 'WiFi password', disabled: true },
})
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('WiFi password')
expect(input.attributes('disabled')).toBeDefined()
})
})
+10
View File
@@ -48,6 +48,10 @@
<!-- Content with 3D transitions -->
<div class="perspective-container-wrapper">
<div class="perspective-container">
<!-- #145: fresh install with no cable — point at the WiFi picker. -->
<!-- Onboarding routes only: logged-in sessions are offline-first and
must never be nagged about connectivity. -->
<OnboardingNetworkCallout v-if="isOnboardingRoute" />
<RouterView v-slot="{ Component, route }">
<Transition :name="transitionName">
<div :key="route.path" class="view-wrapper">
@@ -64,9 +68,15 @@
import { ref, watch, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
import OnboardingNetworkCallout from '@/components/OnboardingNetworkCallout.vue'
const route = useRoute()
const currentBackground = ref('bg-intro.jpg')
// #145: the no-network callout follows the user across onboarding steps, but
// this wrapper also hosts /login (and could host more later) — scope the
// callout to the onboarding flow only.
const isOnboardingRoute = computed(() => route.path.startsWith('/onboarding/'))
const isGlitching = ref(false)
const isTransitioning = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
+15
View File
@@ -970,6 +970,7 @@ onUnmounted(() => disarmVpnPoll())
// outside a <KeepAlive> boundary (confirmed by ServerNetworkRefresh.test.ts,
// which mounts this view bare), so a bare mount must not silently skip them.
onMounted(() => {
consumeOpenWifiQuery()
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary(); loadDiskStatus()
armServerEntryEffects()
})
@@ -977,6 +978,20 @@ onMounted(() => {
watch(showWifiModal, (open) => { if (open) scanWifi() })
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
// #145: onboarding's no-network callout deep-links here with ?open=wifi so a
// fresh-install user lands straight in the WiFi picker. Read from the real
// URL (the dashboard's SPA router keeps it in sync) rather than vue-router —
// the KeepAlive-mounted view has no router guarantee at test-mount time —
// and consume it (history.replaceState) so a tab-return never re-pops.
function consumeOpenWifiQuery() {
const params = new URLSearchParams(window.location.search)
if (params.get('open') !== 'wifi') return
params.delete('open')
const qs = params.toString()
history.replaceState(history.state, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash)
showWifiModal.value = true
}
async function restartServices() {
restarting.value = true; servicesRunning.value = false
try { await rpcClient.restartServer(); logsToast.value = 'Services restarting...'; setTimeout(() => { logsToast.value = '' }, 4000) }
+5 -4
View File
@@ -147,12 +147,12 @@
<!-- WiFi password prompt -->
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
<input
<PasswordRevealInput
v-model="localWifiPassword"
type="password"
placeholder="WiFi password"
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
@keyup.enter="$emit('connectWifi', localWifiPassword)"
:disabled="wifiSubmitting"
class="mb-3"
@enter="$emit('connectWifi', localWifiPassword)"
/>
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
<div class="flex gap-2">
@@ -231,6 +231,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import PasswordRevealInput from '@/components/PasswordRevealInput.vue'
defineProps<{
showAddServiceModal: boolean
File diff suppressed because it is too large Load Diff
+19 -21
View File
@@ -1,33 +1,31 @@
{
"changelog": [
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
],
"components": [
{
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.4-alpha",
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
"size_bytes": 63850680
"new_version": "1.8.5-alpha",
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
"size_bytes": 63934504
},
{
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
"new_version": "1.8.4-alpha",
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
"size_bytes": 97641658
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
"new_version": "1.8.5-alpha",
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
"size_bytes": 97657151
}
],
"release_date": "2026-08-20",
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
"release_date": "2026-08-31",
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.4-alpha"
"version": "1.8.5-alpha"
}
+19 -21
View File
@@ -1,33 +1,31 @@
{
"changelog": [
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
"**Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.",
"**A frozen node now explains itself — and comes back on its own.** The host now captures a memory dump into /var/crash when the kernel panics *or* wedges (a hung kiosk used to sit dead until someone power-cycled it; now it dumps, reboots itself, and leaves the evidence behind), and records failing-memory signals (ECC errors) into a database as they happen. This is the first change delivered by a new host-update channel: the node's own updater now carries OS-level packages and settings to already-deployed machines — the crash-kernel's memory reservation is the one part that waits for a reboot, and the node says so rather than pretending.",
"**Uninstalling an app can no longer report success when it failed.** The declarative path used to swallow every teardown error and report the app uninstalled, leaving the tile behind and the truth in the logs. A failed uninstall now stops and shows the real per-app errors, so \"still there\" is never presented as \"gone\".",
"**Pictures to internet-only mesh contacts work now.** Sending an attachment inline always took the radio path and failed with \"Peer is federation-only (no radio twin)\" for contacts reachable only over the internet — and the size-adviser kept recommending a radio transfer those peers can't receive. Both fixed: inline sends route over the federation when that's the only way to reach the peer, and the advice no longer offers radio-only transfers to radio-unreachable contacts.",
"**Disk cleanup finally has honest numbers.** Space \"free\" on a drive was counted including the slice the filesystem keeps reserved for root — roughly 5% of the disk, 92 GB on one dev box — so the automatic cleanup that's supposed to kick in at 90% never triggered and stale container images piled up unnoticed. Reserved space now counts as used, which is what the threshold was always meant to measure."
],
"components": [
{
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.4-alpha",
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
"size_bytes": 63850680
"new_version": "1.8.5-alpha",
"sha256": "54e91944c6395a53c8ac87ea97f61e8a7fc5ffd133f017180931ba4fa9566239",
"size_bytes": 63934504
},
{
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
"new_version": "1.8.4-alpha",
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
"size_bytes": 97641658
"current_version": "1.8.5-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.5-alpha/archipelago-frontend-1.8.5-alpha.tar.gz",
"name": "archipelago-frontend-1.8.5-alpha.tar.gz",
"new_version": "1.8.5-alpha",
"sha256": "a5d773e8225bfd8a34dfc74acf38bdc2c39bc138c62bf9f4137d0efd72cce3e9",
"size_bytes": 97657151
}
],
"release_date": "2026-08-20",
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
"release_date": "2026-08-31",
"signature": "f0bcec4935588ee428ebabfbb6e08e00dbd42202af92763073a7f98f4785925ca3565bf96fecbdd1695eff77fcf906783fd9053d82730b11c75441c6f5a60b05",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.4-alpha"
"version": "1.8.5-alpha"
}
@@ -0,0 +1,29 @@
{
"changelog": [
"**Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.",
"**What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.",
"**A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines."
],
"components": [
{
"current_version": "1.8.6-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.6-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.6-alpha",
"sha256": "6171e1f46b0e2c2754a18e668f16810338a09b71a68dc365d65173ca1e4d400a",
"size_bytes": 63930112
},
{
"current_version": "1.8.6-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.6-alpha/archipelago-frontend-1.8.6-alpha.tar.gz",
"name": "archipelago-frontend-1.8.6-alpha.tar.gz",
"new_version": "1.8.6-alpha",
"sha256": "1a5b54c516f91a4ea4491e08182f0f9521ee7e27935388814007f91695f06e0f",
"size_bytes": 97769923
}
],
"release_date": "2026-08-31",
"signature": "c21dc5cb5a6caa3253c08835b65d0417a312bc8c51acdf92df990ad2110ae762b77287c479a523a8521827ec7b4695114ff9c39924cf1d30d0d622abb53b9208",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.6-alpha"
}
+18 -5
View File
@@ -1,21 +1,22 @@
#!/bin/bash
# Validate releases/manifest.json:
# Validate the live or a pending release manifest:
# - version matches core/archipelago/Cargo.toml
# - changelog contains curated release notes, not raw git log output
# - every component's download_url exists on disk and matches sha256/size
#
# Run on every push from CI, and also locally before publishing a release:
# scripts/check-release-manifest.sh
# scripts/check-release-manifest.sh [path/to/manifest.json]
#
# Exits non-zero on any mismatch so the release process fails loud.
set -eo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="$REPO_ROOT/releases/manifest.json"
MANIFEST="${1:-$REPO_ROOT/releases/manifest.json}"
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST"
if [ ! -f "$MANIFEST" ]; then
echo "❌ releases/manifest.json missing"
echo "❌ manifest missing: $MANIFEST"
exit 1
fi
@@ -25,6 +26,18 @@ ok() { echo "✅ $*"; }
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
# A prepared release deliberately leaves the live manifest on the previous
# version. Ordinary pushes are therefore harmless: only the publisher promotes
# the pending manifest after its assets have been uploaded and downloaded back.
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ] && [ "$MANIFEST" = "$REPO_ROOT/releases/manifest.json" ]; then
PENDING="$REPO_ROOT/releases/pending/v${CARGO_VERSION}/manifest.json"
if [ -f "$PENDING" ]; then
ok "live manifest remains v${MANIFEST_VERSION} while v${CARGO_VERSION} is pending"
MANIFEST="$PENDING"
MANIFEST_VERSION="$CARGO_VERSION"
fi
fi
if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)"
fi
@@ -105,4 +118,4 @@ for i in $(seq 0 $((COMPONENT_COUNT - 1))); do
done
echo
ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}"
ok "$MANIFEST passes all checks — safe to publish v${MANIFEST_VERSION}"
+12 -8
View File
@@ -261,15 +261,19 @@ content = open('$CHANGELOG_FILE').read()
pattern = r'## .*?${VERSION}.*?\n(.*?)(?=\n## |\Z)'
m = re.search(pattern, content, re.DOTALL)
if m:
for line in m.group(1).strip().split('\n')[:10]:
for line in m.group(1).splitlines():
line = line.strip()
if line:
print(line)
if not line.startswith('- '):
continue
text = line[2:].strip()
if text.lower().startswith('validation '):
continue
print(text)
" 2>/dev/null || echo "")
if [ -n "$ENTRIES" ]; then
CHANGELOG=$(echo "$ENTRIES" | python3 -c "
import sys, json
lines = [l.strip().lstrip('- ') for l in sys.stdin if l.strip()]
lines = [l.strip() for l in sys.stdin if l.strip()]
print(json.dumps(lines))
")
fi
@@ -298,7 +302,7 @@ echo ""
cat "$OUTPUT_FILE"
echo ""
echo "Next steps:"
echo " 1. Review the manifest above"
echo " 2. Upload artifacts to Gitea release v$VERSION"
echo " 3. Commit manifest.json to releases/manifest.json on main"
echo " 4. Tag the release: git tag v$VERSION && git push --tags"
echo " 1. Review and sign the manifest above"
echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest"
echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2"
echo " (it uploads + verifies assets before atomically promoting the manifest)"
+39 -49
View File
@@ -2,7 +2,8 @@
# create-release.sh — Full release automation for Archipelago
#
# Bumps version in Cargo.toml and package.json, generates changelog from git log,
# creates release manifest, and creates git tag.
# creates a pending release manifest, and creates git tag. The live manifest is
# promoted only by publish-release-assets.sh after the assets are verified.
#
# Usage:
# ./scripts/create-release.sh 1.0.0 # Release v1.0.0
@@ -30,9 +31,9 @@ for arg in "$@"; do
echo " 2. Bump version in Cargo.toml and package.json"
echo " 3. Build backend"
echo " 4. Build frontend"
echo " 5. Generate changelog from git log"
echo " 6. Create release manifest"
echo " 7. Commit version bump"
echo " 5. Validate the curated changelog"
echo " 6. Create pending release manifest"
echo " 7. Commit release preparation"
echo " 8. Create git tag v{VERSION}"
echo ""
echo "Options:"
@@ -121,14 +122,13 @@ if $DRY_RUN; then
echo " 2. Update neode-ui/package.json version to $VERSION"
echo " 3. Build backend (cargo build --release -p archipelago)"
echo " 4. Build frontend (npm run build)"
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 6. Create release manifest"
echo " 7. Commit: 'chore: release v${VERSION}'"
echo " 5. Validate the curated changelog"
echo " 6. Create pending release manifest (the live manifest stays unchanged)"
echo " 7. Commit: 'chore: prepare release v${VERSION}'"
echo " 8. Tag: v${VERSION}"
echo ""
echo "After this script, you would:"
echo " - Push: git push && git push --tags"
echo " - Build ISOs on server: ssh archipelago@192.0.2.10"
echo "After this script, publish only with:"
echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2"
exit 0
fi
@@ -214,9 +214,13 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"
exit 1
fi
echo "[6/8] Creating release manifest..."
mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
echo "[6/8] Creating pending release manifest..."
# Never write the fleet-visible path here. A normal `git push main` must not be
# capable of advertising assets which have not been uploaded yet.
PENDING_DIR="$PROJECT_ROOT/releases/pending/v${VERSION}"
PENDING_MANIFEST="$PENDING_DIR/manifest.json"
mkdir -p "$PENDING_DIR"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PENDING_MANIFEST" 2>&1 | grep -v "^$"
# §A supply-chain: the OTA manifest must carry the release-root signature.
# Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh
@@ -239,60 +243,45 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
echo " Enter by itself will NOT submit; pasting twice concatenates"
echo " the phrases and fails on word count."
echo "════════════════════════════════════════════════════════════════"
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
"$SIGNER" ceremony sign "$PENDING_MANIFEST"
"$SIGNER" ceremony verify "$PENDING_MANIFEST"
else
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED."
echo " This run will ABORT before committing (step 7 refuses an unsigned"
echo " manifest), because nodes read releases/manifest.json from branch main"
echo " and would refuse to auto-apply it."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh"
echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — pending manifest left UNSIGNED."
echo " This run will ABORT before committing (step 7 refuses an unsigned manifest)."
echo " Sign it, then re-run: bash scripts/sign-manifest.sh $PENDING_MANIFEST"
fi
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6c/8] Staging release artifacts for validation..."
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz"
mkdir -p "$VERSION_DIR"
install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago"
install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
"$SCRIPT_DIR/check-release-manifest.sh"
"$SCRIPT_DIR/check-release-manifest.sh" "$PENDING_MANIFEST"
# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER,
# because publishing is not the first way an unsigned manifest reaches the
# fleet. Nodes fetch releases/manifest.json straight from branch `main`
# (see the verification URLs printed below), so the COMMIT is what exposes
# it, not the publish. publish-release-assets.sh refusing to ship is a
# backstop that arrives one step too late: by then the unsigned manifest is
# already on main and the fleet is already refusing to auto-apply.
#
# This is why every cycle needed a manual catch. The signing block above is
# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a
# warning and falls through — and the commit then happened anyway. A release
# commit carrying a manifest no node will accept has no valid use, so refuse
# to create one rather than leave a tag that has to be re-cut.
# §A supply-chain gate, mirroring publish-release-assets.sh. The pending path
# prevents an ordinary main push from exposing the release, but an unsigned
# manifest is still unpublishable and must never be tagged as ready.
# Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed
# with the old root (z6Mkkid…q7ur) — it is the release that installed this
# pin on every node. From v1.7.123 onward the new root signs, and nodes
# running .122+ reject anything signed with the old key.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then
if ! grep -q '"signature":' "$PENDING_MANIFEST" \
|| ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PENDING_MANIFEST"; then
echo "" >&2
echo "Error: releases/manifest.json is NOT signed by the release root." >&2
echo " Refusing to commit — nodes read this file from branch main and will" >&2
echo " refuse to auto-apply it, so the release would be dead on arrival." >&2
echo "Error: the pending manifest is NOT signed by the release root." >&2
echo " Refusing to commit an unpublishable release." >&2
echo "" >&2
echo " Sign it, then re-run this script:" >&2
echo " bash scripts/sign-manifest.sh" >&2
echo " bash scripts/sign-manifest.sh $PENDING_MANIFEST" >&2
echo "" >&2
echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2
exit 1
fi
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
"$SIGNER" ceremony verify "$PENDING_MANIFEST" \
|| { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; }
echo "[7/8] Committing version bump..."
echo "[7/8] Committing release preparation..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \
core/Cargo.lock \
@@ -300,15 +289,16 @@ git -C "$PROJECT_ROOT" add \
neode-ui/package-lock.json \
neode-ui/public/catalog.json \
CHANGELOG.md \
releases/manifest.json \
release-manifest.json \
2>/dev/null || true
# releases/** is ignored because binaries live in Gitea attachments; force-add
# only this small signed pending manifest.
git -C "$PROJECT_ROOT" add -f "releases/pending/v${VERSION}/manifest.json"
# Cargo.lock (rewritten by the release build after the version bump) and
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
git -C "$PROJECT_ROOT" commit -m "chore: prepare release v${VERSION}"
echo "[8/8] Creating git tag..."
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
@@ -319,8 +309,8 @@ echo ""
echo "Artifacts:"
echo " - Version bumped in Cargo.toml and package.json"
echo " - Changelog updated in CHANGELOG.md"
echo " - Release manifest: releases/manifest.json"
echo " - Release manifest copy: release-manifest.json"
echo " - Pending manifest: releases/pending/v${VERSION}/manifest.json"
echo " - Live manifest: unchanged until assets pass publication verification"
echo " - Staged artifacts: releases/v${VERSION}/"
echo " - Git tag: v${VERSION}"
echo ""
+54
View File
@@ -84,6 +84,60 @@ else
warn "unmkinitramfs not installed — skipping initrd live-boot check"
fi
# ── Host crash-capture payload (#144) ──────────────────────────────
# Check the built artifact, not just the ISO builder source: otherwise a stale
# rootfs.tar can silently omit the packages/configuration while the build passes.
ROOTFS="$MNT/archipelago/rootfs.tar"
if [ -f "$ROOTFS" ]; then
DPKG_STATUS="$(sudo tar -xOf "$ROOTFS" var/lib/dpkg/status 2>/dev/null || true)"
for package in kdump-tools kexec-tools makedumpfile rasdaemon; do
if awk -v wanted="$package" 'BEGIN { RS=""; FS="\n" }
$0 ~ "(^|\\n)Package: " wanted "(\\n|$)" &&
$0 ~ "(^|\\n)Status: install ok installed(\\n|$)" { found=1 }
END { exit !found }' <<<"$DPKG_STATUS"; then
ok "rootfs package installed: $package"
else
bad "rootfs package missing/not installed: $package"
fi
done
KDUMP_DEFAULTS="$(sudo tar -xOf "$ROOTFS" etc/default/kdump-tools 2>/dev/null || true)"
if grep -qE '^USE_KDUMP=.?1' <<<"$KDUMP_DEFAULTS"; then
ok "rootfs enables kdump"
else
bad "rootfs /etc/default/kdump-tools does not enable kdump"
fi
if grep -qE '^KDUMP_COREDIR=.?/var/crash' <<<"$KDUMP_DEFAULTS"; then
ok "rootfs sends crash dumps to /var/crash"
else
bad "rootfs kdump target is not /var/crash"
fi
KDUMP_SYSCTL="$(sudo tar -xOf "$ROOTFS" etc/sysctl.d/99-archipelago-kdump.conf 2>/dev/null || true)"
for setting in 'kernel.panic = 10' 'kernel.panic_on_oops = 1' \
'kernel.hung_task_panic = 1' 'kernel.hardlockup_panic = 1'; do
if grep -Fqx "$setting" <<<"$KDUMP_SYSCTL"; then
ok "rootfs sysctl: $setting"
else
bad "rootfs missing sysctl: $setting"
fi
done
else
bad "cannot inspect crash-capture payload: missing archipelago/rootfs.tar"
fi
AUTO_INSTALL="$MNT/archipelago/auto-install.sh"
if grep -qF 'crashkernel=256M' "$AUTO_INSTALL" 2>/dev/null; then
ok "installer writes crashkernel=256M"
else
bad "installer does not write crashkernel=256M"
fi
if grep -qF 'Archipelago owns crashkernel sizing in /etc/default/grub' "$AUTO_INSTALL" 2>/dev/null; then
ok "installer neutralizes the conflicting Debian kdump GRUB default"
else
bad "installer does not neutralize the conflicting Debian kdump GRUB default"
fi
# ── Backend binary inside the ISO embeds the expected version ────────
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
# shipped and the fleet rejected its fips.yaml on Activate)
+46 -10
View File
@@ -16,14 +16,26 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
BACKEND="$VERSION_DIR/archipelago"
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
PENDING_MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/manifest.json"
LIVE_MANIFEST="$PROJECT_ROOT/releases/manifest.json"
if [ -f "$PENDING_MANIFEST" ]; then
MANIFEST="$PENDING_MANIFEST"
PROMOTE_MANIFEST=1
else
# Backward compatibility for releases prepared before pending manifests.
MANIFEST="$LIVE_MANIFEST"
PROMOTE_MANIFEST=0
fi
fail() { echo "Error: $*" >&2; exit 1; }
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
[ -f "$MANIFEST" ] || fail "release manifest missing: $MANIFEST"
MANIFEST_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST")
[ "$MANIFEST_VERSION" = "$VERSION" ] || fail "requested v$VERSION but $MANIFEST describes v$MANIFEST_VERSION"
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
"$SCRIPT_DIR/check-release-manifest.sh"
"$SCRIPT_DIR/check-release-manifest.sh" "$MANIFEST"
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
@@ -32,11 +44,11 @@ fail() { echo "Error: $*" >&2; exit 1; }
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
# v1.7.123 onward.
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
grep -q '"signature":' "$MANIFEST" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$MANIFEST" \
|| fail "$MANIFEST is not signed by the release root — run: bash scripts/sign-manifest.sh $MANIFEST"
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$MANIFEST" \
|| fail "manifest signature failed cryptographic verification"
fi
@@ -130,12 +142,36 @@ echo "Verifying public download URLs (full GET + size + sha256)..."
# hand during recovery. It fails hard on the first bad asset — the previous
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
# the subshell and let this script march on to "published and verified".
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
# Assets are proven fetchable — only now does the manifest become live.
echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
git -C "$PROJECT_ROOT" push "$REMOTE" main
# Assets are proven fetchable — only now may the manifest become live. First
# incorporate concurrent work, then promote in a dedicated commit. Until the
# final push succeeds the remote still serves the previous manifest.
echo "Assets verified. Synchronizing main before manifest promotion..."
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
if [ "$PROMOTE_MANIFEST" = "1" ]; then
cp "$MANIFEST" "$LIVE_MANIFEST"
cp "$MANIFEST" "$PROJECT_ROOT/release-manifest.json"
git -C "$PROJECT_ROOT" add releases/manifest.json release-manifest.json
git -C "$PROJECT_ROOT" rm -f -- "releases/pending/v${VERSION}/manifest.json"
git -C "$PROJECT_ROOT" commit -m "chore: publish release v${VERSION}"
fi
echo "Publishing verified manifest to main (this makes v${VERSION} live)..."
# A concurrent push can race the fetch above. Merge and retry without ever
# force-pushing; the remote remains on its old, working manifest meanwhile.
for attempt in 1 2 3; do
if git -C "$PROJECT_ROOT" push "$REMOTE" HEAD:main; then
break
fi
[ "$attempt" -lt 3 ] || fail "main advanced repeatedly; assets are safe but manifest was not promoted"
echo "main advanced during publication; merging and retrying..."
git -C "$PROJECT_ROOT" fetch "$REMOTE" main
git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main"
done
echo "Release v${VERSION} published and verified on $REMOTE."
+9 -6
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# One-step OTA-manifest signer (counterpart to sign-catalog.sh).
#
# Run: bash scripts/sign-manifest.sh
# Run: bash scripts/sign-manifest.sh [path/to/manifest.json]
# Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D.
#
# Signs releases/manifest.json in place and cryptographically verifies the
# Signs the requested manifest (live by default) and cryptographically verifies the
# result against the pinned release-root anchor. The mnemonic is read from the
# terminal only (never stored, never in shell history, never passed to Claude).
#
@@ -18,7 +18,9 @@
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$REPO/releases/manifest.json"
MANIFEST="${1:-$REPO/releases/manifest.json}"
[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO/$MANIFEST"
[ -f "$MANIFEST" ] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; }
# Use ONLY a prebuilt signer — never compile here (compiling caused hangs in
# the earlier catalog ceremony). Prefer the repo's release build.
@@ -41,9 +43,10 @@ echo "════════════════════════
echo
if "$BIN" ceremony verify "$MANIFEST"; then
echo "✅ SUCCESS — manifest signed by the pinned release root."
echo " Commit + push releases/manifest.json (and release-manifest.json if present)."
cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true
echo "✅ SUCCESS — manifest signed by the pinned release root: $MANIFEST"
if [ "$MANIFEST" = "$REPO/releases/manifest.json" ]; then
cp "$MANIFEST" "$REPO/release-manifest.json"
fi
else
echo "❌ Signature did NOT verify against the pinned release-root anchor."
echo " Do NOT commit. Check the mnemonic and re-run."
+106 -28
View File
@@ -11,8 +11,9 @@ a block (from the curated CHANGELOG bullets) and inserts it newest-first.
python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing
Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is
user-facing. Only CHANGELOG versions are managed; older hand-written blocks
(pre-CHANGELOG history) are never touched or removed.
user-facing. The visible history deliberately starts at v1.8.0-alpha; older
blocks are removed so this remains a concise product history rather than an
unbounded archive.
"""
import re
import sys
@@ -25,6 +26,7 @@ MODAL = REPO / "neode-ui/src/views/settings/AccountInfoSection.vue"
MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
MIN_VISIBLE_VERSION = (1, 8, 0)
HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)")
@@ -47,7 +49,10 @@ def parse_changelog():
if m:
ver, y, mo, d = m.groups()
cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []}
entries.append(cur)
if version_key(ver) >= MIN_VISIBLE_VERSION:
entries.append(cur)
else:
cur = None
continue
if cur is not None and line.startswith("- "):
text = line[2:].strip()
@@ -67,14 +72,71 @@ def undated_versions():
return found
def existing_versions():
text = MODAL.read_text()
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text))
def ordered_versions():
"""Return modal versions in display order (top to bottom)."""
return re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", MODAL.read_text())
def version_key(version):
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", version)
return tuple(map(int, match.groups()))
def sort_modal_blocks(entries):
"""Re-render current release-note blocks newest-first and remove old history."""
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- (v\d+\.\d+\.\d+\S*) -->\s*$")
blocks = []
for start, line in enumerate(lines):
match = marker.match(line)
if not match:
continue
depth = 0
opened = False
for index in range(start + 1, len(lines)):
for tag in re.findall(r"</?div\b[^>]*>", lines[index]):
if tag.startswith("</"):
depth -= 1
else:
depth += 1
opened = True
if opened and depth == 0:
blocks.append((start, index + 1, match.group(1), lines[start:index + 1]))
break
else:
raise RuntimeError(f"unclosed What's New block for {match.group(1)}")
if not blocks:
return False
for previous, following in zip(blocks, blocks[1:]):
gap = "".join(lines[previous[1]:following[0]])
if gap.strip():
raise RuntimeError("unexpected content between What's New release blocks")
by_version = {entry["ver"]: entry for entry in entries}
retained = [b for b in blocks if version_key(b[2]) >= MIN_VISIBLE_VERSION]
sorted_segments = [
render_block(by_version[b[2]]).splitlines(keepends=True)
if b[2] in by_version else b[3]
for b in sorted(retained, key=lambda b: version_key(b[2]), reverse=True)
]
output = lines[:blocks[0][0]]
for segment in sorted_segments:
output.extend(segment)
output.extend(lines[blocks[-1][1]:])
changed = output != lines
if changed:
MODAL.write_text("".join(output))
return changed
def to_html(text):
text = text.replace("`", "") # drop markdown code ticks (plain prose)
return html.escape(text, quote=False) # & < > (Vue template-safe)
text = text.replace("`", "")
escaped = html.escape(text, quote=False) # & < > (Vue template-safe)
escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", r"<em>\1</em>", escaped)
return escaped
def render_block(entry):
@@ -113,36 +175,52 @@ def main():
return 1
entries = parse_changelog()
have = existing_versions()
displayed = ordered_versions()
have = set(displayed)
missing = [e for e in entries if e["ver"] not in have]
expected_order = sorted(displayed, key=version_key, reverse=True)
out_of_order = displayed != expected_order
too_old = [v for v in displayed if version_key(v) < MIN_VISIBLE_VERSION]
if not missing:
print("What's New modal is in sync with CHANGELOG.md "
f"({len(entries)} changelog versions, all present).")
if not missing and not out_of_order and not too_old:
changed = False if check else sort_modal_blocks(entries)
if changed:
print("Re-rendered What's New blocks from the curated changelog.")
else:
print("What's New modal is in sync with CHANGELOG.md "
f"({len(entries)} changelog versions, all present and newest-first).")
return 0
names = ", ".join(e["ver"] for e in missing)
if check:
print("FAIL: these CHANGELOG versions have no block in the Settings "
f"What's New modal: {names}", file=sys.stderr)
if missing:
print("FAIL: these CHANGELOG versions have no block in the Settings "
f"What's New modal: {names}", file=sys.stderr)
if out_of_order:
print("FAIL: What's New entries are not newest-first; the modal currently "
f"opens at {displayed[0]} instead of {expected_order[0]}", file=sys.stderr)
if too_old:
print("FAIL: What's New contains entries older than the v1.8.0 history floor: "
+ ", ".join(too_old), file=sys.stderr)
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
return 1
# Insert missing blocks newest-first, immediately before the newest existing
# block marker (the first "<!-- v... -->" line in the file).
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
if idx is None:
print("ERROR: could not find an existing version block marker in the modal.",
file=sys.stderr)
return 2
if missing:
# Insert before the first block; the full sort below makes this safe even
# when a historical hand-written block was accidentally left at the top.
lines = MODAL.read_text().splitlines(keepends=True)
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
if idx is None:
print("ERROR: could not find an existing version block marker in the modal.",
file=sys.stderr)
return 2
lines.insert(idx, "".join(render_block(e) for e in missing))
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
# newest-first: sort missing by their order in `entries` (already newest-first)
block_text = "".join(render_block(e) for e in missing)
lines.insert(idx, block_text)
MODAL.write_text("".join(lines))
print(f"Inserted {len(missing)} block(s): {names}")
if sort_modal_blocks(entries):
print("Normalized What's New blocks (v1.8.0+ only, newest-first).")
return 0
+14 -7
View File
@@ -242,14 +242,21 @@ section_d() {
else
record FAIL "kdump-tools configured" "/etc/default/kdump-tools missing USE_KDUMP=1 — host fixup didn't land"
fi
# D2. crashkernel reservation — memory is reserved at BOOT, so a node that
# took the OTA fixup but hasn't rebooted yet is WARN, not FAIL.
if grep -q 'crashkernel=' /proc/cmdline 2>/dev/null; then
record PASS "crashkernel reserved" "$(grep -oE 'crashkernel=[^ ]+' /proc/cmdline | head -1)"
elif grep -q 'crashkernel=' /etc/default/grub 2>/dev/null; then
record WARN "crashkernel reserved" "written to GRUB — applies on next reboot"
# D2. crashkernel reservation — grade the memory the kernel actually
# reserved, not merely the first matching cmdline token. Debian's
# kdump-tools.cfg used to append a second crashkernel= range after our 256M;
# the audit falsely passed while /sys reported only 192M reserved.
local crash_size expected_size=$((256 * 1024 * 1024))
crash_size=$(cat /sys/kernel/kexec_crash_size 2>/dev/null || echo 0)
[[ "$crash_size" =~ ^[0-9]+$ ]] || crash_size=0
if (( crash_size >= expected_size )); then
record PASS "crashkernel reserved" "$((crash_size / 1024 / 1024))MiB actually reserved"
elif (( crash_size > 0 )); then
record FAIL "crashkernel reserved" "$((crash_size / 1024 / 1024))MiB reserved; expected >=256MiB (conflicting cmdline?)"
elif grep -q 'crashkernel=256M' /etc/default/grub 2>/dev/null; then
record WARN "crashkernel reserved" "256M written to GRUB — applies on next reboot"
else
record FAIL "crashkernel reserved" "absent from /proc/cmdline AND /etc/default/grub"
record FAIL "crashkernel reserved" "no reservation and crashkernel=256M absent from GRUB"
fi
# D3. hang/panic capture policy — runtime-settable, expected immediately
if [[ "$(cat /proc/sys/kernel/hung_task_panic 2>/dev/null)" == "1" ]]; then