Files
Kigi-CLI/crates/codegen/kigi-update/tests/test_install_sh.rs
T
ZacharyZhang-NY 86e3724310 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'.
2026-07-18 00:54:53 -04:00

306 lines
9.5 KiB
Rust

//! Harness for the bootstrap installer (`install.sh` at the repo root), the
//! second client that can brick a machine. Runs the REAL shipped script
//! against a fake `curl` that serves GitHub-Releases-shaped JSON, the
//! archive, and SHA256SUMS from local fixtures, and asserts:
//!
//! > After any install attempt, `$KIGI_SHARE_DIR/bin/kigi` resolves to a
//! > binary that runs, OR the install failed cleanly with nothing activated —
//! > never a partial/garbage binary.
//!
//! Each test uses its own tempdir home and passes PATH/KIGI_SHARE_DIR to the
//! child process explicitly, so no process-global state is touched and no
//! `#[serial]` is needed.
#![cfg(unix)]
mod common;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::{archive_name, host_platform, make_release_archive, sha256_hex, small_good_artifact};
fn install_sh_path() -> Option<PathBuf> {
// crates/codegen/kigi-update → repo root.
dunce::canonicalize(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../install.sh"))
.ok()
.filter(|p| p.exists())
}
/// GitHub release JSON with download URLs whose suffixes the fake curl
/// dispatches on (the host is irrelevant).
fn release_json(version: &str) -> String {
let name = archive_name(version);
serde_json::json!({
"tag_name": format!("v{version}"),
"draft": false,
"prerelease": false,
"assets": [
{ "name": name, "browser_download_url": format!("https://example.test/dl/v{version}/{name}") },
{ "name": "SHA256SUMS", "browser_download_url": format!("https://example.test/dl/v{version}/SHA256SUMS") },
],
})
.to_string()
}
/// Fixture directory holding the fake curl + canned responses.
struct Fixture {
dir: tempfile::TempDir,
home: tempfile::TempDir,
}
impl Fixture {
/// `binary` becomes the `kigi` entry of the served archive; `sums_hash`
/// overrides the manifest hash when `Some` (to simulate corruption).
fn new(version: &str, binary: &[u8], sums_hash: Option<&str>) -> Self {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let archive = make_release_archive(binary);
let hash = match sums_hash {
Some(h) => h.to_string(),
None => sha256_hex(&archive),
};
std::fs::write(dir.path().join("release.json"), release_json(version)).unwrap();
std::fs::write(dir.path().join("archive.tar.gz"), &archive).unwrap();
std::fs::write(
dir.path().join("SHA256SUMS"),
format!("{hash} {}\n", archive_name(version)),
)
.unwrap();
let d = dir.path().to_string_lossy().replace('\'', "'\\''");
let curl = format!(
r#"#!/bin/sh
echo "$@" >> '{d}/curl-args.log'
out=""
url=""
prev=""
for a in "$@"; do
if [ "$prev" = "-o" ]; then out="$a"; fi
case "$a" in
-*) ;;
*) url="$a" ;;
esac
prev="$a"
done
serve() {{
if [ -n "$out" ]; then cat "$1" > "$out"; else cat "$1"; fi
}}
case "$url" in
*/SHA256SUMS) serve '{d}/SHA256SUMS' ;;
*.tar.gz) serve '{d}/archive.tar.gz' ;;
*/latest|*/tags/v*) serve '{d}/release.json' ;;
*) echo "fake curl: unmatched url: $url" >&2; exit 22 ;;
esac
"#
);
let curl_path = dir.path().join("curl");
std::fs::write(&curl_path, curl).unwrap();
std::fs::set_permissions(&curl_path, std::fs::Permissions::from_mode(0o755)).unwrap();
Self { dir, home }
}
fn run(&self, args: &[&str]) -> std::process::Output {
let script = install_sh_path().expect("install.sh present at repo root");
let path = format!(
"{}:{}",
self.dir.path().display(),
std::env::var("PATH").unwrap_or_default()
);
Command::new("sh")
.arg(&script)
.args(args)
.env("PATH", path)
.env("KIGI_SHARE_DIR", self.home.path())
.env("HOME", self.home.path())
.output()
.expect("install.sh must spawn")
}
fn curl_log(&self) -> String {
std::fs::read_to_string(self.dir.path().join("curl-args.log")).unwrap_or_default()
}
fn active_kigi(&self) -> PathBuf {
self.home.path().join("bin").join("kigi")
}
}
fn stderr_of(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
#[test]
fn install_sh_happy_path_installs_versioned_binary_and_symlink() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
let out = fx.run(&[]);
assert!(
out.status.success(),
"install.sh must succeed: stderr={}",
stderr_of(&out)
);
// Managed layout: versioned binary + relative symlink, same as the
// self-updater produces.
let versioned = fx
.home
.path()
.join("downloads")
.join(format!("kigi-0.1.5-{}", host_platform()));
assert!(versioned.exists(), "versioned binary installed");
assert_eq!(std::fs::read(&versioned).unwrap(), small_good_artifact());
let link = fx.active_kigi();
assert!(link.is_symlink(), "bin/kigi is a symlink");
assert_eq!(
std::fs::read_link(&link).unwrap(),
Path::new("..")
.join("downloads")
.join(format!("kigi-0.1.5-{}", host_platform())),
"symlink must be relative (survives bind-mounted homes)"
);
// The active link runs.
let status = Command::new(&link).arg("--version").status().unwrap();
assert!(status.success(), "installed kigi must run");
// Resolved the latest endpoint (no pinned version).
assert!(
fx.curl_log().contains("/latest"),
"must resolve via /latest: {}",
fx.curl_log()
);
}
#[test]
fn install_sh_pinned_version_uses_tag_endpoint() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
let out = fx.run(&["--version", "v0.1.5"]);
assert!(
out.status.success(),
"pinned install must succeed: stderr={}",
stderr_of(&out)
);
assert!(
fx.curl_log().contains("/tags/v0.1.5"),
"must resolve via /tags/v0.1.5: {}",
fx.curl_log()
);
assert!(fx.active_kigi().is_symlink());
}
#[test]
fn install_sh_rejects_checksum_mismatch_and_activates_nothing() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), Some(&"0".repeat(64)));
let out = fx.run(&[]);
assert!(
!out.status.success(),
"checksum mismatch must fail the install"
);
assert!(
stderr_of(&out).contains("SHA256 mismatch"),
"stderr: {}",
stderr_of(&out)
);
let link = fx.active_kigi();
assert!(
!link.exists() && !link.is_symlink(),
"nothing may be activated after a checksum failure"
);
}
#[test]
fn install_sh_rejects_invalid_version_argument() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
let out = fx.run(&["--version", "not-a-version"]);
assert!(!out.status.success());
assert!(
stderr_of(&out).contains("invalid version"),
"stderr: {}",
stderr_of(&out)
);
assert!(
fx.curl_log().is_empty(),
"invalid arguments must fail before any network access"
);
}
#[test]
fn install_sh_fails_when_release_lacks_platform_asset() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
// Rewrite release.json without the platform archive asset.
let json = serde_json::json!({
"tag_name": "v0.1.5",
"assets": [
{ "name": "SHA256SUMS", "browser_download_url": "https://example.test/dl/v0.1.5/SHA256SUMS" },
],
});
std::fs::write(fx.dir.path().join("release.json"), json.to_string()).unwrap();
let out = fx.run(&[]);
assert!(!out.status.success());
assert!(
stderr_of(&out).contains("no asset"),
"stderr: {}",
stderr_of(&out)
);
let link = fx.active_kigi();
assert!(!link.exists() && !link.is_symlink());
}
#[test]
fn install_sh_fails_when_archive_lacks_kigi_binary() {
if install_sh_path().is_none() {
eprintln!("skipping: install.sh not found (vendored sandbox)");
return;
}
let fx = Fixture::new("0.1.5", &small_good_artifact(), None);
// Replace the archive with one that has no `kigi` entry; keep the
// manifest consistent so the checksum gate passes and the extraction
// check is what trips.
let archive = common::make_tar_gz(&[("LICENSE", b"license only")]);
std::fs::write(
fx.dir.path().join("SHA256SUMS"),
format!("{} {}\n", sha256_hex(&archive), archive_name("0.1.5")),
)
.unwrap();
std::fs::write(fx.dir.path().join("archive.tar.gz"), &archive).unwrap();
let out = fx.run(&[]);
assert!(!out.status.success());
assert!(
stderr_of(&out).contains("does not contain a 'kigi' binary"),
"stderr: {}",
stderr_of(&out)
);
let link = fx.active_kigi();
assert!(!link.exists() && !link.is_symlink());
}