- .github/workflows/release.yml: on tag v* build all 5 targets (macOS
arm64/x86_64, Linux arm64/x86_64 incl. free arm runners, Windows
x86_64) with the release-dist profile, archive kigi-<version>-<triple>
with LICENSE/NOTICE/THIRD-PARTY-NOTICES, generate SHA256SUMS, publish
the release (prerelease for tags containing '-'), with a tag↔workspace
version guard.
- install.sh / install.ps1 (repo root): platform detection, latest or
--version download from GitHub Releases, SHA-256 verification against
SHA256SUMS, install into the kigi home's downloads/ + bin/kigi symlink
(the same layout the self-updater manages), smoke test, PATH guidance.
- kigi-update rewritten onto the GitHub Releases API (documented wire
shape; stable=/latest, alpha=semver-max across the list, pinned=/tags):
SHA-256 gate before any binary swap, tar.gz/zip extraction per
platform, atomic bin/kigi symlink swap, channel/rollback semantics and
the KIGI_AUTO_UPDATE gate preserved verbatim; every x.ai/GCS/npm
endpoint deleted, npm/gh-release installers removed, legacy grok/agent
links retired on install. kigi-env owns the update base URL with a
KIGI_UPDATE_BASE_URL override (this is what the test artifact server
injects).
- .cargo/config.toml: removed the non-portable neoverse-v2 CPU pin on
Linux arm64 (fleet-specific); RELRO/NX hardening link-args now apply
to the gnu targets too, matching the release-dist profile's contract.
- THIRD-PARTY-NOTICES regenerated via cargo-about (about.toml +
template); the M0 hand-built file is dropped and README points at the
generated one. docs/RELEASE.md carries the release checklist.
- Deleted xAI-era leftovers: kigi-tui/scripts/install*.{sh,ps1} (x.ai
CDN) and the @xai-official/grok npm skeleton (PRD F8: no npm).
Gates: fmt clean; workspace check/clippy 0/0 (--locked, -D warnings);
kigi-update 58 lib + 86 integration tests green; deny ok;
release-dist build of kigi-bin succeeds and reports 'kigi 0.1.0'.
503 lines
19 KiB
Rust
503 lines
19 KiB
Rust
//! End-to-end tests for the production update flows (`check_update_status`,
|
|
//! `ensure_latest_on_disk`, `run_update`, `run_update_if_available`) against
|
|
//! a GitHub-Releases-shaped [`common::artifact_server::ArtifactServer`],
|
|
//! injected via the `KIGI_UPDATE_BASE_URL` override that
|
|
//! `kigi_env::update_base_url()` honors.
|
|
//!
|
|
//! Three invariant families:
|
|
//!
|
|
//! 1. **Convergence**: a binary already on disk (installed by another
|
|
//! process) is never downloaded a second time, but stale runners still
|
|
//! get the relaunch/report signal.
|
|
//! 2. **Status**: `kigi update --check` reports upgrades only, surfaces
|
|
//! fetch errors in the `error` field, and never advertises downgrades.
|
|
//! 3. **Race integrity**: concurrent installers — even for different
|
|
//! versions — never leave a corrupt active binary.
|
|
//!
|
|
//! Everything here is `#[serial]`: KIGI_SHARE_DIR, KIGI_UPDATE_BASE_URL and
|
|
//! KIGI_TEST_VERSION are process-global.
|
|
|
|
#![cfg(unix)]
|
|
|
|
mod common;
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
|
|
use serial_test::serial;
|
|
|
|
use common::artifact_server::ArtifactServer;
|
|
use common::{
|
|
can_exec_shell_scripts, host_platform, make_update_config, reset_home, set_test_version,
|
|
set_update_base, small_good_artifact, test_home,
|
|
};
|
|
use kigi_update::auto_update::{
|
|
UpdateRunMode, check_update_status, ensure_latest_on_disk, install_internal_from_base,
|
|
run_update, run_update_if_available,
|
|
};
|
|
use kigi_update::version::installed_on_disk_version;
|
|
|
|
/// Lay down a managed-install layout in the test KIGI_SHARE_DIR:
|
|
/// `bin/kigi -> ../downloads/kigi-<version>-<platform>` (what the installer
|
|
/// produces; the canonical link the disk-version probe reads).
|
|
fn fake_managed_install(version: &str) {
|
|
let home = test_home();
|
|
let downloads = home.join("downloads");
|
|
let bin = home.join("bin");
|
|
std::fs::create_dir_all(&downloads).unwrap();
|
|
std::fs::create_dir_all(&bin).unwrap();
|
|
let name = format!("kigi-{version}-{}", host_platform());
|
|
std::fs::write(downloads.join(&name), small_good_artifact()).unwrap();
|
|
std::fs::set_permissions(
|
|
downloads.join(&name),
|
|
std::fs::Permissions::from_mode(0o755),
|
|
)
|
|
.unwrap();
|
|
let _ = std::fs::remove_file(bin.join("kigi"));
|
|
std::os::unix::fs::symlink(Path::new("../downloads").join(&name), bin.join("kigi")).unwrap();
|
|
}
|
|
|
|
/// Assert the active `~/.kigi/bin/kigi` resolves to the expected versioned
|
|
/// binary, actually runs, and has exactly the expected content (the content
|
|
/// check is what catches a cross-racer temp-file corruption).
|
|
fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_content: &[u8]) {
|
|
let link = home.join("bin").join("kigi");
|
|
assert!(link.is_symlink(), "kigi must be a symlink");
|
|
let resolved = dunce::canonicalize(&link)
|
|
.unwrap_or_else(|e| panic!("active kigi symlink does not resolve: {e}"));
|
|
assert_eq!(
|
|
resolved.file_name().unwrap().to_string_lossy(),
|
|
format!("kigi-{version}-{platform}"),
|
|
"active kigi must be the expected version"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(&resolved).unwrap(),
|
|
expected_content,
|
|
"active binary content must be exactly the served artifact (no \
|
|
partial/interleaved writes from a racing updater)"
|
|
);
|
|
let ran_ok = std::process::Command::new(&resolved)
|
|
.arg("--version")
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
assert!(ran_ok, "active kigi must pass the smoke-test");
|
|
}
|
|
|
|
fn setup(server: &ArtifactServer, latest: &str, running: &str) {
|
|
let _ = test_home();
|
|
reset_home();
|
|
server.set_latest(latest);
|
|
set_update_base(&server.base());
|
|
set_test_version(running);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Convergence: ensure_latest_on_disk downloads once, then every subsequent
|
|
// pass (the leader's hourly re-entry) converges without re-downloading.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn ensure_latest_downloads_once_then_converges_without_redownload() {
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.7", "0.2.5");
|
|
let cfg = make_update_config("stable");
|
|
|
|
// Pass 1: disk is empty → downloads and installs.
|
|
let first = ensure_latest_on_disk(&cfg).await.unwrap();
|
|
assert_eq!(first.installed.as_deref(), Some("0.2.7"));
|
|
assert!(first.relaunch_needed, "running 0.2.5 < disk 0.2.7");
|
|
assert_eq!(server.request_count(), 1, "first pass downloads");
|
|
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
|
|
|
|
// Pass 2 (the pre-fix hourly re-download): disk already current →
|
|
// no download, but the stale running process still gets the relaunch
|
|
// signal.
|
|
let second = ensure_latest_on_disk(&cfg).await.unwrap();
|
|
assert_eq!(second.installed, None, "second pass must not re-download");
|
|
assert!(second.relaunch_needed, "still running 0.2.5 < disk 0.2.7");
|
|
assert_eq!(
|
|
server.request_count(),
|
|
1,
|
|
"hourly re-entry must not download again"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn run_update_skips_download_when_disk_already_current() {
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.7", "0.2.5");
|
|
// Another process (TUI background download) already installed 0.2.7.
|
|
fake_managed_install("0.2.7");
|
|
let mut cfg = make_update_config("stable");
|
|
|
|
let result = run_update(false, None, None, &mut cfg).await.unwrap();
|
|
|
|
assert_eq!(
|
|
result.as_deref(),
|
|
Some("0.2.7"),
|
|
"run_update must still report the on-disk target so the caller \
|
|
signals stale leaders to relaunch"
|
|
);
|
|
assert_eq!(
|
|
server.request_count(),
|
|
0,
|
|
"a binary someone else installed must not be downloaded again"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn run_update_force_still_redownloads_when_disk_current() {
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.7", "0.2.7");
|
|
fake_managed_install("0.2.7");
|
|
let mut cfg = make_update_config("stable");
|
|
|
|
let result = run_update(true, None, None, &mut cfg).await.unwrap();
|
|
|
|
assert_eq!(result.as_deref(), Some("0.2.7"));
|
|
assert_eq!(
|
|
server.request_count(),
|
|
1,
|
|
"--force must bypass the disk-current skip and reinstall"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn run_update_rolls_back_when_latest_moved_backwards() {
|
|
// Release rollback: the latest release points BELOW the on-disk install
|
|
// (a bad release was deleted). The internal installer is authoritative,
|
|
// so run_update must converge the disk down to it.
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.5", "0.2.7");
|
|
fake_managed_install("0.2.7");
|
|
let mut cfg = make_update_config("stable");
|
|
|
|
let result = run_update(false, None, None, &mut cfg).await.unwrap();
|
|
|
|
assert_eq!(
|
|
result.as_deref(),
|
|
Some("0.2.5"),
|
|
"rollback target installed"
|
|
);
|
|
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.5"));
|
|
assert_eq!(server.request_count(), 1);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Disk-version probe
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn disk_probe_preserves_prerelease_versions() {
|
|
let _ = test_home();
|
|
reset_home();
|
|
// An alpha install must read back as the full pre-release version —
|
|
// truncating to "0.1.220" would mask the alpha → stable update.
|
|
fake_managed_install("0.1.220-alpha.4");
|
|
assert_eq!(
|
|
installed_on_disk_version().as_deref(),
|
|
Some("0.1.220-alpha.4")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn disk_probe_rejects_dangling_symlink() {
|
|
// If the symlink survives but its target binary was deleted (manual
|
|
// ~/.kigi/downloads cleanup), the probe must report None — otherwise
|
|
// every updater would claim "already up to date" forever while no
|
|
// runnable binary exists, and nothing would ever repair the install.
|
|
let home = test_home();
|
|
reset_home();
|
|
let platform = host_platform();
|
|
fake_managed_install("0.2.7");
|
|
assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7"));
|
|
|
|
std::fs::remove_file(
|
|
home.join("downloads")
|
|
.join(format!("kigi-0.2.7-{platform}")),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
installed_on_disk_version(),
|
|
None,
|
|
"a dangling symlink must not report an installed version"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn ensure_latest_repairs_dangling_symlink_by_downloading() {
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
// Dangling symlink + stale running process: the probe returns None, so
|
|
// the decision falls back to the running version and the download runs,
|
|
// repairing the install instead of wedging on "already up to date".
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.7", "0.2.5");
|
|
let home = test_home();
|
|
let platform = host_platform();
|
|
fake_managed_install("0.2.7");
|
|
std::fs::remove_file(
|
|
home.join("downloads")
|
|
.join(format!("kigi-0.2.7-{platform}")),
|
|
)
|
|
.unwrap();
|
|
let cfg = make_update_config("stable");
|
|
|
|
let outcome = ensure_latest_on_disk(&cfg).await.unwrap();
|
|
|
|
assert_eq!(
|
|
outcome.installed.as_deref(),
|
|
Some("0.2.7"),
|
|
"dangling symlink must be repaired by an actual download"
|
|
);
|
|
assert_eq!(server.request_count(), 1);
|
|
assert_eq!(
|
|
installed_on_disk_version().as_deref(),
|
|
Some("0.2.7"),
|
|
"probe healthy again after the repair install"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// check_update_status (`kigi update --check`)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn check_status_reports_update_when_release_is_newer() {
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.7", "0.2.5");
|
|
let cfg = make_update_config("stable");
|
|
|
|
let status = check_update_status(&cfg).await;
|
|
|
|
assert_eq!(status.current_version, "0.2.5");
|
|
assert_eq!(status.latest_version.as_deref(), Some("0.2.7"));
|
|
assert!(status.update_available);
|
|
assert_eq!(status.installer.as_deref(), Some("internal"));
|
|
assert_eq!(status.error, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn check_status_never_reports_downgrade_as_update() {
|
|
// --check reports upgrades only; a rolled-back release is not advertised
|
|
// (auto-update converges separately).
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.5", "0.2.7");
|
|
let cfg = make_update_config("stable");
|
|
|
|
let status = check_update_status(&cfg).await;
|
|
|
|
assert_eq!(status.latest_version.as_deref(), Some("0.2.5"));
|
|
assert!(!status.update_available, "downgrade must not be advertised");
|
|
assert_eq!(status.error, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn check_status_surfaces_fetch_error_in_error_field() {
|
|
// Point the updater at a dead endpoint (bound then dropped port →
|
|
// connection refused). The status must carry the error rather than
|
|
// pretending "up to date".
|
|
let _ = test_home();
|
|
reset_home();
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
set_update_base(&format!("http://127.0.0.1:{port}/releases"));
|
|
set_test_version("0.2.5");
|
|
let cfg = make_update_config("stable");
|
|
|
|
let status = check_update_status(&cfg).await;
|
|
|
|
assert!(!status.update_available);
|
|
assert_eq!(status.latest_version, None);
|
|
let err = status.error.as_deref().expect("error must be surfaced");
|
|
assert!(!err.is_empty());
|
|
|
|
// And it serializes into the --json contract.
|
|
let v = serde_json::to_value(&status).unwrap();
|
|
assert!(v["error"].is_string());
|
|
assert_eq!(v["updateAvailable"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn check_status_unsupported_channel_reports_error() {
|
|
let server = ArtifactServer::start(small_good_artifact());
|
|
setup(&server, "0.2.7", "0.2.5");
|
|
let cfg = make_update_config("beta");
|
|
|
|
let status = check_update_status(&cfg).await;
|
|
|
|
assert!(!status.update_available);
|
|
let err = status.error.as_deref().expect("channel error surfaced");
|
|
assert!(
|
|
err.contains("Unsupported release channel 'beta'"),
|
|
"err: {err}"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// run_update_if_available — the auto-update opt-out gate.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn run_update_if_available_respects_auto_update_false() {
|
|
// With cli.auto_update = false persisted, the startup check must return
|
|
// without ever touching the network (the update base points at a dead
|
|
// port — any fetch would error, any download would install).
|
|
let _ = test_home();
|
|
reset_home();
|
|
std::fs::write(
|
|
test_home().join("config.toml"),
|
|
"[cli]\nauto_update = false\n",
|
|
)
|
|
.unwrap();
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
set_update_base(&format!("http://127.0.0.1:{port}/releases"));
|
|
set_test_version("0.1.0");
|
|
let cfg = make_update_config("stable");
|
|
|
|
let ran = run_update_if_available(UpdateRunMode::Blocking, false, &cfg)
|
|
.await
|
|
.unwrap();
|
|
assert!(!ran, "auto_update=false must suppress the update entirely");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Race integrity: the accepted same-instant race must stay harmless. Two (or
|
|
// three) installers running concurrently — even for DIFFERENT versions —
|
|
// must never leave a corrupt active binary.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
async fn run_concurrent_installs(
|
|
server: &ArtifactServer,
|
|
versions: &[&str],
|
|
) -> Vec<anyhow::Result<()>> {
|
|
let base = server.base();
|
|
let mut tasks = Vec::new();
|
|
for version in versions {
|
|
let base = base.clone();
|
|
let version = version.to_string();
|
|
tasks.push(tokio::spawn(async move {
|
|
let cfg = make_update_config("stable");
|
|
install_internal_from_base(Some(&version), &cfg, &base).await
|
|
}));
|
|
}
|
|
let mut results = Vec::new();
|
|
for t in tasks {
|
|
results.push(t.await.expect("install task must not panic"));
|
|
}
|
|
results
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn concurrent_same_version_installs_leave_valid_active_binary() {
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
let home = test_home();
|
|
reset_home();
|
|
let platform = host_platform();
|
|
let artifact = small_good_artifact();
|
|
let server = ArtifactServer::start(artifact.clone());
|
|
// Hold responses open so the racers genuinely overlap mid-download.
|
|
server.set_slow(true);
|
|
|
|
let results = run_concurrent_installs(&server, &["0.1.181", "0.1.181", "0.1.181"]).await;
|
|
for r in results {
|
|
r.expect("every racing install must succeed (atomic swap, last writer wins)");
|
|
}
|
|
|
|
// Lock-free model: concurrent racers may each download (accepted waste);
|
|
// the invariant is integrity, not the count.
|
|
assert!(server.request_count() >= 1);
|
|
assert_active_binary(home, "0.1.181", &platform, &artifact);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial]
|
|
async fn concurrent_different_version_installs_do_not_corrupt_each_other() {
|
|
if !can_exec_shell_scripts() {
|
|
eprintln!("skipping: shell scripts cannot execute in this sandbox");
|
|
return;
|
|
}
|
|
let home = test_home();
|
|
reset_home();
|
|
let platform = host_platform();
|
|
let artifact = small_good_artifact();
|
|
let server = ArtifactServer::start(artifact.clone());
|
|
server.set_slow(true);
|
|
|
|
let results = run_concurrent_installs(&server, &["0.1.181", "0.1.182"]).await;
|
|
for r in results {
|
|
r.expect("both racing installs must succeed");
|
|
}
|
|
|
|
// Both versioned binaries must exist with full, uncorrupted content.
|
|
for version in ["0.1.181", "0.1.182"] {
|
|
let path = home
|
|
.join("downloads")
|
|
.join(format!("kigi-{version}-{platform}"));
|
|
assert_eq!(
|
|
std::fs::read(&path).unwrap(),
|
|
artifact,
|
|
"binary {version} must contain exactly the served artifact"
|
|
);
|
|
}
|
|
|
|
// The active symlink points at whichever racer swapped last; it must
|
|
// resolve and run regardless.
|
|
let resolved = dunce::canonicalize(home.join("bin").join("kigi")).unwrap();
|
|
assert_eq!(std::fs::read(&resolved).unwrap(), artifact);
|
|
let name = resolved.file_name().unwrap().to_string_lossy().to_string();
|
|
assert!(
|
|
!name.contains(".tmp"),
|
|
"active kigi must never be a temp file: {name}"
|
|
);
|
|
|
|
// No stray shared temp file left behind (a with_extension-style
|
|
// collision name).
|
|
assert!(
|
|
!home.join("downloads").join("kigi-0.1.tmp").exists(),
|
|
"the shared-temp-name collision must not exist"
|
|
);
|
|
}
|