Nightly CLI channel: rolling prerelease workflow, sha-aware self-update, update timer (#3107)

* Add nightly CLI channel: rolling prerelease workflow, sha-aware self-update, update timer

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* TEMP: trigger nightly on PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Raise the launchd daemon file-descriptor limit to 8192

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Remove temporary nightly trigger for the PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly: save the rust cache, note why Linux builds on 22.04

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Address review: semver-ordered stable updates, assert nightly target commit, quote systemd ExecStart

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly: skip the xtask daemon build, retry release creation

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* TEMP: trigger nightly on PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Nightly: remove the runner's libunwind before setup.sh

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Remove temporary nightly trigger for the PR branch

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
This commit is contained in:
Jamie Pineandcapy-ai-staging[bot] authored and GitHub committed 2026-10-05 04:25:44 +00:00
1 parent 9ad30afc20
commit 5e285d3c97
12 files changed
+1014 -127

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
# Nightly CLI and daemon builds.
#
# Every push to the branch below builds sd-cli and sd-daemon in release mode
# and republishes them on the rolling `nightly` prerelease, whose tag moves to
# the built commit. `sd update` on the nightly channel reads that commit from
# the release and installs the binaries when it differs from the one the
# installed CLI was built from (see docs/core/releases.mdx).
#
# These binaries are not signed with a Developer ID and are not notarized. The
# macOS linker adds an ad-hoc signature, which is enough to run a file the CLI
# downloaded itself; a copy fetched through a browser carries the quarantine
# attribute and Gatekeeper refuses it until that attribute is removed.
#
# The binaries are built with sd-core's default features: no bundled FFmpeg,
# libheif or Pdfium, so they do not depend on apps/.deps at runtime. macOS
# reaches the same formats through its system codecs. Because `-p sd-cli -p
# sd-core` without the `ffmpeg` feature never compiles crates/ffmpeg, the Linux
# job can use ubuntu-22.04, whose FFmpeg 4.4 is too old for sd-ffmpeg and
# breaks the full-workspace clippy job in ci.yml.
name: Nightly
on:
push:
branches:
# The branch the nightly channel tracks. Change this one line when the
# rewrite moves to main.
- sources
workflow_dispatch:
env:
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
NIGHTLY_TAG: nightly
# A second push while a build is running makes the first result stale.
concurrency:
group: nightly
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
matrix:
settings:
- host: macos-15
target: aarch64-apple-darwin
platform: macos-aarch64
- host: blacksmith-4vcpu-ubuntu-2204
target: x86_64-unknown-linux-gnu
platform: linux-x86_64
name: Build ${{ matrix.settings.platform }}
runs-on: ${{ matrix.settings.host }}
timeout-minutes: 90
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
# setup-system is not used here: its `cargo xtask setup` builds a release
# sd-daemon for Tauri's bundler check, which the real build below redoes
# with sd-cli's feature set, and the generated .cargo/config.toml only
# matters with the codec bundle. Static OpenSSL is set below instead so
# the Linux binary does not depend on the runner's libssl.
- name: Setup Rust
uses: ./.github/actions/setup-rust
with:
target: ${{ matrix.settings.target }}
# Save the cargo cache so a warm build does not pay the cold
# dependency compile on every push.
save-cache: 'true'
# Same workaround as setup-system: the runner's preinstalled libunwind
# blocks libgstreamer1.0-dev, which setup.sh installs.
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get remove libunwind-* || true
./scripts/setup.sh
- name: Build sd-cli and sd-daemon
env:
SD_GIT_SHA: ${{ github.sha }}
OPENSSL_STATIC: '1'
OPENSSL_NO_VENDOR: '0'
run: |
cargo build --release --locked \
-p sd-cli -p sd-core --bin sd-cli --bin sd-daemon \
--target ${{ matrix.settings.target }}
# Names are <binary>-<platform>; the updater strips the platform and
# expects exactly `sd` and `sd-daemon`.
- name: Prepare binaries and checksums
run: |
set -eux
mkdir -p dist
platform=${{ matrix.settings.platform }}
cp target/${{ matrix.settings.target }}/release/sd-cli "dist/sd-$platform"
cp target/${{ matrix.settings.target }}/release/sd-daemon "dist/sd-daemon-$platform"
chmod +x dist/*
cd dist
for f in "sd-$platform" "sd-daemon-$platform"; do
if command -v sha256sum >/dev/null; then
sha256sum "$f" > "$f.sha256"
else
shasum -a 256 "$f" > "$f.sha256"
fi
done
cat ./*.sha256
"./sd-$platform" --version
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: nightly-${{ matrix.settings.platform }}
path: dist/*
if-no-files-found: error
publish:
name: Publish nightly prerelease
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: nightly-*
merge-multiple: true
path: dist
# Delete and recreate rather than edit: the release's target commit is
# fixed at creation, and the updater reads it to decide whether to
# install.
- name: Move the nightly release to this commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -eux
ls -l dist
gh release delete "$NIGHTLY_TAG" --cleanup-tag --yes || true
short=$(echo "${{ github.sha }}" | cut -c1-12)
cat > notes.md <<EOF
Rolling nightly build of the CLI and daemon from \`${{ github.ref_name }}\` at ${{ github.sha }}.
Unsigned, may break, replaced on every push. Opt in with \`sd config set update.channel nightly\` and run \`sd update\`; see docs/core/releases.mdx.
EOF
# GitHub occasionally answers the create with HTTP 403 right after
# the tag was deleted; a short retry rides it out.
for attempt in 1 2 3 4 5; do
if gh release create "$NIGHTLY_TAG" \
--target "${{ github.sha }}" \
--prerelease \
--title "Nightly $short" \
--notes-file notes.md \
dist/*; then
break
fi
if [ "$attempt" = 5 ]; then exit 1; fi
echo "release create failed (attempt $attempt), retrying in 15s"
gh release delete "$NIGHTLY_TAG" --cleanup-tag --yes || true
sleep 15
done
# GitHub keeps an existing tag's commit over --target, and the
# updater keys on this field, so a stale tag must fail the job.
actual=$(gh release view "$NIGHTLY_TAG" --json targetCommitish --jq .targetCommitish)
if [ "$actual" != "${{ github.sha }}" ]; then
echo "nightly release targets $actual, expected ${{ github.sha }}"
exit 1
fi
Generated
+4 -1
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "Inflector"
@@ -11435,13 +11435,16 @@ dependencies = [
"dirs 5.0.1",
"indicatif",
"inquire",
"libc",
"qr2term",
"ratatui 0.26.3",
"reqwest 0.12.23",
"sd-client",
"sd-core",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"uuid",
]
+1
View File
@@ -201,6 +201,7 @@ Recent landed.
| Linux integration-test triage | in progress 2026-10-04 (Fable, SPAC-6); 7 of the 13 red `core/tests` targets green on a bare Linux VM | Fable | Each of the 20 failures from SPAC-2 was reproduced on 61dea60 and classed. Stale assertions fixed with no product change: `library_test` expected a `sidecars` dir that ba8efcd stopped creating (now checks `previews` and `exports`), `event_filtering_test` listed the subfolder itself as a "subdirectory only" path (exact mode matches direct children since cfa31cc), `sync_event_log_test` read the newest-first query as oldest-first and raced the startup pruner, `sync_metrics_test` assumed no change arrives before the first explicit action (library open broadcasts the device record), the two volume capacity tests compared ext4 usable bytes to the image size. Environment assumptions made hermetic: `resource_events_test` indexes a seeded temp dir instead of `~/Desktop` and asserts on the file batch, `volume_tracking_test` builds a loop-backed volume when the machine has no user-visible mount (Linux hides `/`), skipping only without sudo. Still red, product bugs rather than tests: pull transfers compare raw blake3 against the sampled content hash (`copy/strategy.rs:705` vs `file_transfer.rs:1252`, every pull fails checksum), the Linux fs-watcher turns a move out of the watched root into a Modify instead of a Remove (`platform/linux.rs:83`, `watcher_test` is right), two cores sharing a hostname collide on `devices.slug` during backfill (`sync_backfill_test`), and after an in-process restart neither peer reconnects (`file_transfer_with_restart_test`, which also needs `core.shutdown()` rather than `drop` to release `secrets.redb`). Next: brief fixes for the four bugs; decide whether `core_tests.yml` runs on PRs (see `reports/SPAC-6.md` in the captain drive). |
| Peer reconnect after restart | in progress 2026-10-04 (Fable, SPAC-8); `file_transfer_with_restart_test` green on a Linux VM | Fable | Bug 4 from the triage. Two causes. `NetworkingService::shutdown` never closed the iroh endpoint, so an in-process restart left the old socket alive with the same node id: the peer never saw a disconnect and the restarted core's dials by node id timed out against its own ghost. Shutdown now aborts the periodic reconnection task and closes the endpoint, which sends CONNECTION_CLOSE to every peer. The reconnection loop was bounded (10 dials, 5 s apart) with no bound on a single dial, the device with the higher EndpointId never dialed at all, and the periodic sweep ignored any device seen within five minutes. `service/network/core/reconnect.rs` now decides the dial role (lower EndpointId initiates, the other side waits a 15 s grace period and then dials anyway) and the backoff (2 s doubling to 30 s, 12 attempts), each dial is bounded by a 30 s timeout, the loop stops once the registry shows the device connected, one loop per device is enforced, and the 30 s sweep re-arms any device still unconnected. Next: live proof between a Mac and the NAS; the suite now runs in `cargo xtask test-core`. |
| Pairing bugs from the integration triage | fixed 2026-10-04 (Fable, SPAC-7); `file_copy_pull_test`, `watcher_test`, `sync_backfill_test` green on Linux | Fable | Three product bugs SPAC-6 located. Pull transfers always failed verification: the receiver hashed every byte with blake3 while the sender sent the 16-hex sampled content hash, and the push receiver never verified at all; `TransferComplete.final_checksum` is now the full blake3 of the file bytes in both directions (`ContentHashGenerator::generate_integrity_hash`, computed while streaming on the sender), compared on both receivers, with a corrupted file deleted and reported as `TransferError`. The Linux watcher turned a move out of the watched root into a Modify of a path that no longer existed: `RawEventKind::Rename` now carries inotify's From/To/Both, a lone MOVED_FROM becomes Remove and a lone MOVED_TO becomes Create after a 200 ms pairing window, and a buffered Modify is dropped when its path is removed. Two devices with the same hostname collided on `devices.slug` during sync apply (collision handling existed on insert only, not on update), so backfill failed every 5 s forever; the slug now gets a suffix from the device id on both paths, and three consecutive backfill failures log an error and pause sync instead of looping. `test_volume_resource_events_on_sync` now accepts the coarse `Refresh` that #3061 made backfill emit. Still red in `sd-fs-watcher --lib` and out of scope: `test_file_deletion_events` reads the write's buffered Modify before the Remove, and `a_shallow_watch_delivers_events_for_immediate_children` is filtered because `tempfile` dirs contain `.tmp`. Next: bug 4 (no reconnect after in-process restart) in its own thread; decide whether `core_tests.yml` runs on pull requests. |
| Nightly CLI channel | in progress 2026-10-04 (Fable, SPAC-10); workflow, updater and docs in review | Fable | Every push to `sources` builds `sd` and `sd-daemon` (release, default features, macOS ARM64 and Linux x86-64) and republishes them on the rolling `nightly` prerelease with sha256 files, its tag moved to the built commit (`.github/workflows/nightly.yml`; the branch is one line in the trigger). `sd update` on `update.channel = nightly` fetches that release by tag and compares the release's target commit with the commit compiled into the binary (`SD_GIT_SHA` from `apps/cli/build.rs`, shown by `sd --version`); the stable channel still compares the latest release tag with the package version. The asset matcher strips the platform and expects exactly `sd` (or `sd-cli`) and `sd-daemon`, so the two no longer collide; downloads are checked against their `.sha256`; replacement writes a temp file and renames over the running binary; a launchd-managed daemon is restarted with `launchctl kickstart`. `sd update --yes` is the non-interactive path and `sd update install-timer` writes a launchd agent (systemd timer on Linux) that runs it hourly. Default `update.repo` is now `spacedriveapp/spacedrive`. Next: merge, watch the first real nightly run, opt both Macs in. |
| `core_tests.yml` on pull requests | landed 2026-10-05 (Fable, SPAC-11); unit and integration jobs green on the first Blacksmith run | Fable | The workflow was dispatch-only with the two traps SPAC-4 removed from `ci.yml` (`maximize-build-space`, runner label in the `target` field). It now runs on push and pull requests against `sources` as two `blacksmith-4vcpu-ubuntu-2404` jobs: unit (`cargo xtask test-core --unit` plus `sd-fs-watcher --lib`, 12 min cold) and integration (`cargo xtask test-core --integration`, 17 suites, 49 min cold of which 46 min is the suites themselves; the six two-core suites take 4 to 6 min each by design). `xtask test-core` gained `--unit` and `--integration`. Each job has its own `Swatinem/rust-cache` key, `scripts/setup.sh` installs libav, bun 1.3.4 with `--frozen-lockfile` serves the TypeScript bridge suites, and the loop-device volume helper now reports a missing `losetup` device as the skip reason (Blacksmith grants passwordless sudo, so the volume suites ran for real). The two `sd-fs-watcher --lib` failures are fixed: `EventFilters::should_skip` matched `.tmp` anywhere in the path and hid every file under a `tempfile` dir, so temp and system filters now look at the file name only; `test_file_deletion_events` drains the file's events until Remove and asserts no Create slipped in, instead of assuming the write's buffered Modify arrives before the delete. Next: cut the integration wall time by splitting the two-core suites across jobs if 50 min proves too slow for the PR loop. |
## Product sequence
+5
View File
@@ -26,7 +26,12 @@ ratatui = "0.26"
reqwest = { version = "0.12", features = ["json"] }
sd-client = { path = "../../crates/sd-client" }
sd-core = { path = "../../core", features = ["cli"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["serde", "v4"] }
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
+28
View File
@@ -0,0 +1,28 @@
use std::process::Command;
/// Embeds the git commit the CLI was built from as `SD_GIT_SHA`.
///
/// The nightly channel moves one `nightly` tag along a branch, so the package
/// version never changes between nightly builds and the updater compares
/// commits instead. CI sets `SD_GIT_SHA` explicitly because a shallow or
/// detached checkout may still report the right commit through `git`, but the
/// workflow's own sha is the one the release is tagged with.
fn main() {
println!("cargo:rerun-if-env-changed=SD_GIT_SHA");
println!("cargo:rerun-if-changed=../../.git/HEAD");
let sha = std::env::var("SD_GIT_SHA")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
})
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=SD_GIT_SHA={}", sha);
}
+4 -4
View File
@@ -18,17 +18,17 @@ pub struct CliConfig {
/// Update configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateConfig {
/// GitHub repository for releases (e.g., "spacedriveapp/spacedrive-cli-releases")
/// GitHub repository whose releases carry the sd and sd-daemon binaries
pub repo: String,
/// Update channel (stable, beta, nightly)
/// Update channel: "stable" follows the latest release, "nightly" follows
/// the rolling `nightly` prerelease
pub channel: String,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
// Placeholder - users should set this to their releases repo
repo: "spacedriveapp/spacedrive-cli-releases".to_string(),
repo: "spacedriveapp/spacedrive".to_string(),
channel: "stable".to_string(),
}
}
+7 -1
View File
@@ -72,7 +72,8 @@ async fn install_launchd_service(data_dir: PathBuf, instance: Option<String>) ->
program_args.push(inst.clone());
}
// Build the plist XML
// launchd starts agents with a 256 file-descriptor limit, which the daemon
// warns about; SoftResourceLimits raises it for watchers and stores.
let label = if let Some(ref inst) = instance {
format!("com.spacedrive.daemon.{}", inst)
} else {
@@ -97,6 +98,11 @@ async fn install_launchd_service(data_dir: PathBuf, instance: Option<String>) ->
<key>SuccessfulExit</key>
<false/>
</dict>
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>8192</integer>
</dict>
<key>StandardOutPath</key>
<string>{stdout_log}</string>
<key>StandardErrorPath</key>
+363 -111
View File
@@ -1,17 +1,44 @@
use anyhow::Result;
//! # Self-update
//!
//! `sd update` replaces the `sd` and `sd-daemon` binaries next to the running
//! executable with the ones published on GitHub for this platform. The stable
//! channel follows the repository's latest release and compares its tag with
//! the package version. The nightly channel follows the rolling `nightly`
//! prerelease that `.github/workflows/nightly.yml` moves along a branch, so
//! the package version never changes between builds and the updater compares
//! the commit the binary was built from (`SD_GIT_SHA`, shown by
//! `sd --version`) with the commit the release was tagged at.
//!
//! Nightly binaries are not signed with a Developer ID, so a checksum asset is
//! verified before anything is written. Replacement goes through a temporary
//! file and a rename, which keeps a running binary intact if the download or
//! the write fails part way.
mod timer;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::PathBuf;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use crate::config::CliConfig;
pub use timer::{run_timer, TimerCmd};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CURRENT_SHA: &str = env!("SD_GIT_SHA");
const NIGHTLY_CHANNEL: &str = "nightly";
const NIGHTLY_TAG: &str = "nightly";
/// Version string for `sd --version`: package version plus the build commit.
pub const VERSION_STRING: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SD_GIT_SHA"), ")");
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
name: String,
#[serde(default)]
target_commitish: String,
assets: Vec<GitHubAsset>,
prerelease: bool,
}
#[derive(Debug, Deserialize)]
@@ -21,32 +48,74 @@ struct GitHubAsset {
size: u64,
}
pub async fn run(data_dir: PathBuf, force: bool) -> Result<()> {
let config = CliConfig::load(&data_dir)?;
pub struct UpdateOptions {
/// Reinstall even when the installed build already matches the release.
pub force: bool,
/// Skip the confirmation prompt, for timers and scripts.
pub yes: bool,
}
println!("Current version: {}", CURRENT_VERSION);
pub async fn run(
data_dir: PathBuf,
instance: Option<String>,
socket_addr: String,
options: UpdateOptions,
) -> Result<()> {
let config = CliConfig::load(&data_dir)?;
let nightly = config.update.channel == NIGHTLY_CHANNEL;
println!("Current version: {}", VERSION_STRING);
println!("Update repository: {}", config.update.repo);
println!("Update channel: {}", config.update.channel);
println!();
// Check for updates
println!("Checking for updates...");
let latest_release = fetch_latest_release(&config.update.repo).await?;
let release = if nightly {
fetch_release(&config.update.repo, &format!("tags/{}", NIGHTLY_TAG)).await?
} else {
fetch_release(&config.update.repo, "latest").await?
};
let latest_version = latest_release.tag_name.trim_start_matches('v');
println!("Latest version: {}", latest_version);
let (installed, available, up_to_date) = if nightly {
let remote_sha = release.target_commitish.trim().to_string();
if !looks_like_sha(&remote_sha) {
return Err(anyhow::anyhow!(
"Nightly release '{}' does not name the commit it was built from (target_commitish = '{}'); the workflow must create it with --target <sha>",
release.tag_name,
remote_sha
));
}
let same = sha_matches(CURRENT_SHA, &remote_sha);
(short_sha(CURRENT_SHA), short_sha(&remote_sha), same)
} else {
let latest = release.tag_name.trim_start_matches('v').to_string();
(
CURRENT_VERSION.to_string(),
latest.clone(),
!is_newer_version(&latest, CURRENT_VERSION),
)
};
if latest_version == CURRENT_VERSION && !force {
println!("You are already on the latest version!");
println!("Available: {}", available);
if up_to_date && !options.force {
if nightly {
println!(
"You are already on the latest nightly build ({}).",
installed
);
} else {
println!(
"No newer release than {} is published; you are up to date.",
installed
);
}
return Ok(());
}
if !force {
if !options.force && !options.yes {
println!();
println!(
"Update available: {} -> {}",
CURRENT_VERSION, latest_version
);
println!("Update available: {} -> {}", installed, available);
println!("Do you want to update? (y/N)");
let mut response = String::new();
@@ -58,70 +127,105 @@ pub async fn run(data_dir: PathBuf, force: bool) -> Result<()> {
}
}
// Determine platform
let platform = get_platform_string();
println!();
println!("Platform: {}", platform);
// Find matching assets
let sd_asset = latest_release
.assets
.iter()
.find(|a| a.name.contains(&platform) && a.name.contains("sd"))
let sd_asset = find_binary_asset(&release.assets, &platform, "sd")
.ok_or_else(|| anyhow::anyhow!("Could not find sd binary for platform: {}", platform))?;
let daemon_asset = latest_release
.assets
.iter()
.find(|a| a.name.contains(&platform) && a.name.contains("sd-daemon"))
.ok_or_else(|| {
let daemon_asset =
find_binary_asset(&release.assets, &platform, "sd-daemon").ok_or_else(|| {
anyhow::anyhow!("Could not find sd-daemon binary for platform: {}", platform)
})?;
println!("Downloading updates...");
println!("Downloading {}...", sd_asset.name);
let sd_data = download_verified(&release.assets, sd_asset).await?;
println!("Downloading {}...", daemon_asset.name);
let daemon_data = download_verified(&release.assets, daemon_asset).await?;
// Download binaries
let sd_data = download_file(&sd_asset.browser_download_url, sd_asset.size).await?;
let daemon_data = download_file(&daemon_asset.browser_download_url, daemon_asset.size).await?;
// Get current binary paths
// Replace the file that is actually running (it may be named sd or sd-cli)
// and the daemon next to it.
let current_exe = std::env::current_exe()?;
let current_exe = current_exe.canonicalize().unwrap_or(current_exe);
let bin_dir = current_exe
.parent()
.ok_or_else(|| anyhow::anyhow!("Could not determine binary directory"))?;
let sd_path = bin_dir.join("sd");
let daemon_path = bin_dir.join("sd-daemon");
println!();
println!("Installing updates...");
// Check if daemon is running
let daemon_was_running = check_daemon_running(&data_dir).await;
let client = sd_client::CoreClient::new(socket_addr);
let daemon_was_running = sd_client::is_daemon_running(&client).await;
if daemon_was_running {
println!("Stopping daemon...");
stop_daemon(&data_dir).await?;
client
.send_raw_request(&sd_core::infra::daemon::types::DaemonRequest::Shutdown)
.await?;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// Perform atomic replacement
replace_binary(&sd_path, &sd_data)?;
replace_binary(&current_exe, &sd_data)?;
replace_binary(&daemon_path, &daemon_data)?;
println!("Update complete!");
if daemon_was_running {
println!("Starting daemon...");
start_daemon(&data_dir).await?;
restart_daemon(&client, &daemon_path, &data_dir, instance.as_deref()).await?;
}
println!();
println!("Successfully updated to version {}", latest_version);
println!("Successfully updated to {}", available);
Ok(())
}
async fn fetch_latest_release(repo: &str) -> Result<GitHubRelease> {
let url = format!("https://api.github.com/repos/{}/releases/latest", repo);
/// Starts the daemon again after its binary changed.
///
/// When launchd owns the daemon, `kickstart -k` restarts it under launchd so it
/// keeps the agent's restart policy; spawning it ourselves would leave an
/// orphan the agent no longer tracks. Everywhere else the daemon is spawned the
/// same way `sd start` does.
async fn restart_daemon(
client: &sd_client::CoreClient,
daemon_path: &Path,
data_dir: &Path,
instance: Option<&str>,
) -> Result<()> {
#[cfg(target_os = "macos")]
{
if let Some(label) = timer::loaded_daemon_agent(instance) {
let status = std::process::Command::new("/bin/launchctl")
.args([
"kickstart",
"-k",
&format!("gui/{}/{}", timer::uid(), label),
])
.status()?;
if status.success() {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
return Ok(());
}
println!("launchctl kickstart failed, starting the daemon directly");
}
}
let launch = sd_client::DaemonLaunchConfig {
daemon_path: daemon_path.to_path_buf(),
data_dir: data_dir.to_path_buf(),
instance: instance.map(str::to_string),
no_default_sources: false,
};
sd_client::ensure_daemon(client, &launch).await?;
Ok(())
}
async fn fetch_release(repo: &str, selector: &str) -> Result<GitHubRelease> {
let url = format!(
"https://api.github.com/repos/{}/releases/{}",
repo, selector
);
let client = reqwest::Client::builder()
.user_agent("spacedrive-cli")
@@ -131,7 +235,9 @@ async fn fetch_latest_release(repo: &str) -> Result<GitHubRelease> {
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to fetch releases: HTTP {}",
"Failed to fetch release {} from {}: HTTP {}",
selector,
repo,
response.status()
));
}
@@ -140,6 +246,44 @@ async fn fetch_latest_release(repo: &str) -> Result<GitHubRelease> {
Ok(release)
}
/// Downloads an asset and checks it against its `.sha256` sibling when the
/// release ships one.
async fn download_verified(assets: &[GitHubAsset], asset: &GitHubAsset) -> Result<Vec<u8>> {
let data = download_file(&asset.browser_download_url, asset.size).await?;
let checksum_name = format!("{}.sha256", asset.name);
let Some(checksum_asset) = assets.iter().find(|a| a.name == checksum_name) else {
println!(
" no {} published, skipping checksum verification",
checksum_name
);
return Ok(data);
};
let checksum_text = download_file(&checksum_asset.browser_download_url, checksum_asset.size)
.await
.with_context(|| format!("downloading {}", checksum_name))?;
let expected = parse_sha256(&String::from_utf8_lossy(&checksum_text))
.ok_or_else(|| anyhow::anyhow!("{} does not contain a sha256 digest", checksum_name))?;
let actual = Sha256::digest(&data)
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
if actual != expected {
return Err(anyhow::anyhow!(
"Checksum mismatch for {}: expected {}, got {}",
asset.name,
expected,
actual
));
}
println!(" sha256 verified");
Ok(data)
}
async fn download_file(url: &str, expected_size: u64) -> Result<Vec<u8>> {
let client = reqwest::Client::builder()
.user_agent("spacedrive-cli")
@@ -167,47 +311,108 @@ async fn download_file(url: &str, expected_size: u64) -> Result<Vec<u8>> {
Ok(bytes.to_vec())
}
fn replace_binary(path: &PathBuf, data: &[u8]) -> Result<()> {
/// Replaces `path` atomically: the new bytes land in a sibling temp file that
/// is renamed over the target, so a running binary is never truncated or
/// partially overwritten.
fn replace_binary(path: &Path, data: &[u8]) -> Result<()> {
use std::fs;
// Create backup
let backup_path = path.with_extension("bak");
if path.exists() {
fs::copy(path, &backup_path)?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("Invalid binary path: {}", path.display()))?;
let staged = path.with_file_name(format!("{}.update", file_name));
let backup = path.with_file_name(format!("{}.bak", file_name));
fs::write(&staged, data).with_context(|| format!("writing {}", staged.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?;
}
// Write new binary
match fs::write(path, data) {
if path.exists() {
fs::copy(path, &backup)?;
}
match fs::rename(&staged, path) {
Ok(()) => {
// Set executable permissions (Unix only)
// We guard this block so it is not compiled on Windows
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
}
// Remove backup on success
if backup_path.exists() {
let _ = fs::remove_file(&backup_path);
}
let _ = fs::remove_file(&backup);
Ok(())
}
Err(e) => {
// Restore from backup on failure
if backup_path.exists() {
let _ = fs::copy(&backup_path, path);
let _ = fs::remove_file(&backup_path);
if backup.exists() {
let _ = fs::rename(&backup, path);
}
Err(e.into())
let _ = fs::remove_file(&staged);
Err(e).with_context(|| format!("replacing {}", path.display()))
}
}
}
fn get_platform_string() -> String {
/// Picks the release asset for one binary on one platform.
///
/// Asset names are `<binary>-<platform>` with optional `.exe`; stripping the
/// platform leaves the binary name, so `sd` never matches `sd-daemon`. The
/// stable server tarball names the client `sd-cli`, which is the same binary.
fn find_binary_asset<'a>(
assets: &'a [GitHubAsset],
platform: &str,
binary: &str,
) -> Option<&'a GitHubAsset> {
assets.iter().find(|a| {
let name = a.name.strip_suffix(".exe").unwrap_or(&a.name);
let Some(stem) = name.strip_suffix(platform) else {
return false;
};
let stem = stem.trim_end_matches(['-', '_']);
match binary {
"sd" => stem == "sd" || stem == "sd-cli",
other => stem == other,
}
})
}
/// True when `candidate` is a strictly newer semantic version than
/// `current`. The repository's latest release can predate the installed
/// build (the v1 desktop releases live under the same repository), so a
/// mismatch alone is not an update. Tags that do not parse as semver are
/// treated as newer so a renamed scheme still reaches the user.
fn is_newer_version(candidate: &str, current: &str) -> bool {
match (
semver::Version::parse(candidate),
semver::Version::parse(current),
) {
(Ok(candidate), Ok(current)) => candidate > current,
_ => candidate != current,
}
}
fn parse_sha256(text: &str) -> Option<String> {
let token = text.split_whitespace().next()?;
(token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()))
.then(|| token.to_ascii_lowercase())
}
fn looks_like_sha(s: &str) -> bool {
s.len() >= 7 && s.len() <= 40 && s.chars().all(|c| c.is_ascii_hexdigit())
}
/// Equal when one sha is a prefix of the other, so an abbreviated commit still
/// matches the full one.
fn sha_matches(a: &str, b: &str) -> bool {
let a = a.to_ascii_lowercase();
let b = b.to_ascii_lowercase();
looks_like_sha(&a) && looks_like_sha(&b) && (a.starts_with(&b) || b.starts_with(&a))
}
fn short_sha(sha: &str) -> String {
sha.chars().take(12).collect()
}
/// Platform suffix shared by the release workflows and the updater.
pub fn get_platform_string() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
@@ -227,40 +432,87 @@ fn get_platform_string() -> String {
}
}
async fn check_daemon_running(data_dir: &PathBuf) -> bool {
let socket_addr = sd_client::daemon_socket_addr(None).to_string();
let client = sd_client::CoreClient::new(socket_addr);
#[cfg(test)]
mod tests {
use super::*;
sd_client::is_daemon_running(&client).await
}
async fn stop_daemon(data_dir: &PathBuf) -> Result<()> {
let socket_addr = sd_client::daemon_socket_addr(None).to_string();
let client = sd_client::CoreClient::new(socket_addr);
client
.send_raw_request(&sd_core::infra::daemon::types::DaemonRequest::Shutdown)
.await?;
// Wait for shutdown
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
Ok(())
}
async fn start_daemon(data_dir: &PathBuf) -> Result<()> {
let current_exe = std::env::current_exe()?;
let daemon_path = current_exe.parent().unwrap().join("sd-daemon");
let mut command = std::process::Command::new(daemon_path);
command.arg("--data-dir").arg(data_dir);
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::null());
command.spawn()?;
// Wait for startup
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
Ok(())
fn asset(name: &str) -> GitHubAsset {
GitHubAsset {
name: name.to_string(),
browser_download_url: String::new(),
size: 0,
}
}
#[test]
fn sd_and_daemon_resolve_to_distinct_assets() {
let assets = vec![
asset("sd-daemon-macos-aarch64"),
asset("sd-daemon-macos-aarch64.sha256"),
asset("sd-macos-aarch64"),
asset("sd-macos-aarch64.sha256"),
asset("sd-linux-x86_64"),
];
let sd = find_binary_asset(&assets, "macos-aarch64", "sd").unwrap();
let daemon = find_binary_asset(&assets, "macos-aarch64", "sd-daemon").unwrap();
assert_eq!(sd.name, "sd-macos-aarch64");
assert_eq!(daemon.name, "sd-daemon-macos-aarch64");
assert!(find_binary_asset(&assets, "windows-x86_64", "sd").is_none());
}
#[test]
fn stable_names_and_windows_suffix_still_match() {
let assets = vec![
asset("sd-cli-linux-x86_64"),
asset("sd-daemon-windows-x86_64.exe"),
asset("sd-windows-x86_64.exe"),
];
assert_eq!(
find_binary_asset(&assets, "linux-x86_64", "sd")
.unwrap()
.name,
"sd-cli-linux-x86_64"
);
assert_eq!(
find_binary_asset(&assets, "windows-x86_64", "sd")
.unwrap()
.name,
"sd-windows-x86_64.exe"
);
assert_eq!(
find_binary_asset(&assets, "windows-x86_64", "sd-daemon")
.unwrap()
.name,
"sd-daemon-windows-x86_64.exe"
);
}
#[test]
fn sha_comparison_accepts_prefixes() {
assert!(sha_matches(
"46a54ca0000000000000000000000000deadbeef",
"46a54ca"
));
assert!(!sha_matches("46a54ca", "ebee7ae"));
assert!(!sha_matches("unknown", "46a54ca"));
}
#[test]
fn older_release_is_not_an_update() {
assert!(!is_newer_version("0.4.3", "2.0.0-alpha.2"));
assert!(!is_newer_version("2.0.0-alpha.2", "2.0.0-alpha.2"));
assert!(is_newer_version("2.0.0-beta.1", "2.0.0-alpha.2"));
assert!(is_newer_version("2.0.0", "2.0.0-beta.1"));
assert!(is_newer_version("weekly-12", "2.0.0-alpha.2"));
}
#[test]
fn checksum_files_parse_first_token() {
assert_eq!(
parse_sha256(&format!("{} sd-macos-aarch64\n", "a".repeat(64))).as_deref(),
Some("a".repeat(64).as_str())
);
assert!(parse_sha256("not a digest").is_none());
}
}
+320
View File
@@ -0,0 +1,320 @@
//! Periodic `sd update --yes` as a user service: a launchd agent on macOS and a
//! systemd user timer on Linux, mirroring `sd daemon install`.
use anyhow::Result;
use clap::Subcommand;
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
pub enum TimerCmd {
/// Install a user service that runs `sd update --yes` periodically
InstallTimer {
/// Seconds between update checks
#[arg(long, default_value_t = 3600)]
interval: u32,
},
/// Remove the periodic update service
UninstallTimer,
/// Show whether the periodic update service is installed
TimerStatus,
}
pub async fn run_timer(data_dir: PathBuf, instance: Option<String>, cmd: TimerCmd) -> Result<()> {
match cmd {
TimerCmd::InstallTimer { interval } => install(data_dir, instance, interval),
TimerCmd::UninstallTimer => uninstall(instance),
TimerCmd::TimerStatus => status(instance),
}
}
fn label(instance: Option<&str>) -> String {
match instance {
Some(inst) => format!("com.spacedrive.update.{}", inst),
None => "com.spacedrive.update".to_string(),
}
}
/// The command the service runs. The absolute path of the running executable
/// is used because launchd and systemd start services with a minimal PATH.
fn update_command(data_dir: &PathBuf, instance: Option<&str>) -> Result<Vec<String>> {
let exe = std::env::current_exe()?;
let exe = exe.canonicalize().unwrap_or(exe);
let mut args = vec![
exe.to_string_lossy().to_string(),
"--data-dir".to_string(),
data_dir.to_string_lossy().to_string(),
];
if let Some(inst) = instance {
args.push("--instance".to_string());
args.push(inst.to_string());
}
args.push("update".to_string());
args.push("--yes".to_string());
Ok(args)
}
#[cfg(target_os = "macos")]
pub fn uid() -> u32 {
// SAFETY: getuid has no preconditions and cannot fail.
unsafe { libc::getuid() }
}
#[cfg(target_os = "macos")]
fn launch_agent_path(name: &str) -> Result<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
Ok(home
.join("Library/LaunchAgents")
.join(format!("{}.plist", name)))
}
/// Label of the daemon launch agent `sd daemon install` wrote, when launchd
/// has it loaded, so an update can restart the daemon through launchd.
#[cfg(target_os = "macos")]
pub fn loaded_daemon_agent(instance: Option<&str>) -> Option<String> {
let label = match instance {
Some(inst) => format!("com.spacedrive.daemon.{}", inst),
None => "com.spacedrive.daemon".to_string(),
};
if !launch_agent_path(&label).ok()?.exists() {
return None;
}
let loaded = std::process::Command::new("/bin/launchctl")
.args(["list", &label])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
loaded.then_some(label)
}
#[cfg(target_os = "macos")]
fn install(data_dir: PathBuf, instance: Option<String>, interval: u32) -> Result<()> {
let label = label(instance.as_deref());
let plist_path = launch_agent_path(&label)?;
std::fs::create_dir_all(plist_path.parent().unwrap())?;
let log_dir = data_dir.join("logs");
std::fs::create_dir_all(&log_dir)?;
let log_path = log_dir.join("update.log");
let program_args = update_command(&data_dir, instance.as_deref())?
.iter()
.map(|arg| format!("\t\t<string>{}</string>", arg))
.collect::<Vec<_>>()
.join("\n");
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
{program_args}
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>{interval}</integer>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
label = label,
program_args = program_args,
interval = interval,
log = log_path.display(),
);
// Reload so a changed interval or binary path takes effect.
if plist_path.exists() {
let _ = std::process::Command::new("/bin/launchctl")
.args(["unload", &plist_path.to_string_lossy()])
.output();
}
std::fs::write(&plist_path, plist)?;
println!("Created LaunchAgent: {}", plist_path.display());
let output = std::process::Command::new("/bin/launchctl")
.args(["load", &plist_path.to_string_lossy()])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to load LaunchAgent: {}",
String::from_utf8_lossy(&output.stderr)
));
}
println!(
"Update timer installed: `sd update --yes` runs now and every {} seconds.",
interval
);
println!("Log: {}", log_path.display());
Ok(())
}
#[cfg(target_os = "macos")]
fn uninstall(instance: Option<String>) -> Result<()> {
let plist_path = launch_agent_path(&label(instance.as_deref()))?;
if !plist_path.exists() {
println!("Update timer is not installed.");
return Ok(());
}
let _ = std::process::Command::new("/bin/launchctl")
.args(["unload", &plist_path.to_string_lossy()])
.output();
std::fs::remove_file(&plist_path)?;
println!("Update timer uninstalled.");
Ok(())
}
#[cfg(target_os = "macos")]
fn status(instance: Option<String>) -> Result<()> {
let label = label(instance.as_deref());
let plist_path = launch_agent_path(&label)?;
if !plist_path.exists() {
println!("Update timer: Not installed");
println!("To install: sd update install-timer");
return Ok(());
}
println!("Update timer: Installed");
println!("LaunchAgent: {}", plist_path.display());
let loaded = std::process::Command::new("/bin/launchctl")
.args(["list", &label])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
println!(
"Service status: {}",
if loaded { "Loaded" } else { "Not loaded" }
);
Ok(())
}
#[cfg(target_os = "linux")]
fn unit_paths(instance: Option<&str>) -> Result<(PathBuf, String)> {
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
let name = match instance {
Some(inst) => format!("spacedrive-update@{}", inst),
None => "spacedrive-update".to_string(),
};
Ok((home.join(".config/systemd/user"), name))
}
#[cfg(target_os = "linux")]
fn install(data_dir: PathBuf, instance: Option<String>, interval: u32) -> Result<()> {
let (dir, name) = unit_paths(instance.as_deref())?;
std::fs::create_dir_all(&dir)?;
// systemd splits ExecStart on whitespace and expands `%` specifiers, so
// each argument is quoted and `%` doubled.
let exec_start = update_command(&data_dir, instance.as_deref())?
.iter()
.map(|arg| {
format!(
"\"{}\"",
arg.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('%', "%%")
)
})
.collect::<Vec<_>>()
.join(" ");
std::fs::write(
dir.join(format!("{}.service", name)),
format!(
"[Unit]\nDescription=Spacedrive update check\n\n[Service]\nType=oneshot\nExecStart={}\n",
exec_start
),
)?;
std::fs::write(
dir.join(format!("{}.timer", name)),
format!(
"[Unit]\nDescription=Spacedrive update check\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec={}s\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",
interval
),
)?;
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.output();
let output = std::process::Command::new("systemctl")
.args(["--user", "enable", "--now", &format!("{}.timer", name)])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to enable timer: {}",
String::from_utf8_lossy(&output.stderr)
));
}
println!(
"Update timer installed: `sd update --yes` runs every {} seconds ({}.timer).",
interval, name
);
Ok(())
}
#[cfg(target_os = "linux")]
fn uninstall(instance: Option<String>) -> Result<()> {
let (dir, name) = unit_paths(instance.as_deref())?;
let timer = dir.join(format!("{}.timer", name));
if !timer.exists() {
println!("Update timer is not installed.");
return Ok(());
}
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "--now", &format!("{}.timer", name)])
.output();
let _ = std::fs::remove_file(&timer);
let _ = std::fs::remove_file(dir.join(format!("{}.service", name)));
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.output();
println!("Update timer uninstalled.");
Ok(())
}
#[cfg(target_os = "linux")]
fn status(instance: Option<String>) -> Result<()> {
let (dir, name) = unit_paths(instance.as_deref())?;
if !dir.join(format!("{}.timer", name)).exists() {
println!("Update timer: Not installed");
println!("To install: sd update install-timer");
return Ok(());
}
println!("Update timer: Installed ({}.timer)", name);
let output = std::process::Command::new("systemctl")
.args(["--user", "is-active", &format!("{}.timer", name)])
.output()?;
println!(
"Service status: {}",
String::from_utf8_lossy(&output.stdout).trim()
);
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn install(_data_dir: PathBuf, _instance: Option<String>, _interval: u32) -> Result<()> {
Err(anyhow::anyhow!(
"The update timer is only available on macOS (launchd) and Linux (systemd)"
))
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn uninstall(_instance: Option<String>) -> Result<()> {
println!("Update timer is not supported on this platform.");
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn status(_instance: Option<String>) -> Result<()> {
println!("Update timer is not supported on this platform.");
Ok(())
}
+24 -7
View File
@@ -136,7 +136,11 @@ fn reset_spacedrive_v2_data(data_dir: &Path) -> Result<()> {
}
#[derive(Parser, Debug)]
#[command(name = "spacedrive", about = "Spacedrive v2 CLI (daemon client)")]
#[command(
name = "spacedrive",
about = "Spacedrive v2 CLI (daemon client)",
version = update::VERSION_STRING
)]
struct Cli {
/// Path to spacedrive data directory
#[arg(long)]
@@ -259,11 +263,16 @@ enum Commands {
},
/// Interactive cloud storage setup
Cloud,
/// Update CLI and daemon to latest version
/// Update CLI and daemon to the latest build on the configured channel
Update {
/// Force update even if already on latest version
/// Reinstall even if already on the latest build
#[arg(long)]
force: bool,
/// Do not ask for confirmation (for timers and scripts)
#[arg(long, short = 'y')]
yes: bool,
#[command(subcommand)]
timer: Option<update::TimerCmd>,
},
}
@@ -484,10 +493,18 @@ async fn main() -> Result<()> {
// Daemon management doesn't need the client, handle directly
daemon::run(data_dir, instance, cmd).await?;
}
Commands::Update { force } => {
// Update doesn't need the client
update::run(data_dir, force).await?;
}
Commands::Update { force, yes, timer } => match timer {
Some(cmd) => update::run_timer(data_dir, instance, cmd).await?,
None => {
update::run(
data_dir,
instance,
socket_addr,
update::UpdateOptions { force, yes },
)
.await?
}
},
_ => {
run_client_command(cli.command, cli.format, data_dir, socket_addr, cli.device).await?;
}
+5 -3
View File
@@ -72,9 +72,11 @@ daemon key manager.
## Auto-start and updates
The installed release keeps client and daemon versions together. Update
replacement is atomic and verifies signed release artifacts before restarting
the daemon.
The installed release keeps client and daemon versions together. `sd update`
replaces both binaries atomically, verifies the published checksums, and
restarts the daemon. `update.channel` selects the stable release or the
rolling nightly build, and `sd update install-timer` checks on a schedule; see
[Releases](/core/releases#nightly-channel).
Platform service integration starts the daemon at login or boot. Its service
definition passes the same instance and data-directory arguments used during
+78
View File
@@ -77,6 +77,84 @@ metadata are signed with the platform or Spacedrive release key.
Stable container releases update the **latest** tag. Prereleases keep their
explicit version tag.
## Nightly channel
The nightly channel is a rolling build of the CLI and daemon for people who
want to track the development branch without compiling. Every push to that
branch runs `.github/workflows/nightly.yml`, which builds `sd` and `sd-daemon`
in release mode for macOS ARM64 and Linux x86-64 and republishes them on the
GitHub prerelease tagged `nightly`. The tag moves to the built commit each
time, so there is one nightly release and it always names the commit it came
from.
Nightly builds are not a release:
- They are not signed with a Developer ID and are not notarized. The macOS
linker adds an ad-hoc signature, which is enough to run a binary the CLI
downloaded itself. A copy downloaded in a browser carries the quarantine
attribute and Gatekeeper refuses it until you remove that attribute.
- They can break, lose data, or change schemas without migrations. Keep
backups of any library you point a nightly build at.
- They carry sd-core's default features only: no bundled FFmpeg, libheif, or
Pdfium. macOS reaches those formats through its system codecs.
Each binary ships with a `.sha256` file, which `sd update` verifies before it
writes anything.
### Opting in
~~~bash
sd config set update.repo spacedriveapp/spacedrive
sd config set update.channel nightly
sd update
~~~
The repository line matters on installs whose `cli.json` was written before
the default pointed at this repository. On the nightly channel, `sd update`
fetches the `nightly` release by tag and compares the commit it was built at
with the commit the installed binary was built from. `sd --version` prints
both the package version and that commit, for example
`2.0.0-alpha.2 (46a54caf3be5...)`. Builds from a tree without git report
`unknown` and always see an update.
`sd update --yes` skips the confirmation prompt and does nothing when the
installed commit already matches. `sd update --force` reinstalls the current
nightly even when it matches. Both replace the running `sd` binary and the
`sd-daemon` next to it through a temporary file and a rename, stop the daemon
first, and start it again afterwards. When `sd daemon install` has launchd
managing the daemon, the restart goes through `launchctl kickstart` so launchd
keeps owning the process.
### Updating on a schedule
~~~bash
sd update install-timer # every hour
sd update install-timer --interval 21600
sd update timer-status
sd update uninstall-timer
~~~
On macOS this writes `~/Library/LaunchAgents/com.spacedrive.update.plist` with
a `StartInterval`, running the installed `sd` with `--data-dir` and, when set,
`--instance`, followed by `update --yes`. Output goes to `logs/update.log` in
the data directory. On Linux it writes a systemd user timer and oneshot service
named `spacedrive-update`. The service runs the binary at the absolute path of
the `sd` that installed it, so move the binary and reinstall the timer
together.
### Going back to stable
~~~bash
sd config set update.channel stable
sd update
~~~
The stable channel follows the repository's latest release and installs it
only when its tag is a newer semantic version than the installed package
version. The repository's latest release is still a v1 desktop build, so a
2.0 nightly on the stable channel reports that it is up to date until the
first 2.0 release ships standalone `sd` and `sd-daemon` assets.
## Required gates
### Repository