F8: distribution and GitHub-Releases self-update
- .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'.
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user