1 //! #32/#49: release install resolve+stage+handoff + dry-run + 03_release artifact.2 3 use std::path::{Path, PathBuf};4 use std::process::Command;5 6 fn bin() -> Command {7 Command::new(env!("CARGO_BIN_EXE_f0rge"))8 }9 10 fn run_in(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> (String, String, i32) {11 let mut c = bin();12 c.current_dir(dir).args(args);13 for (k, v) in envs {14 c.env(k, v);15 }16 let out = c.output().expect("run f0rge");17 (18 String::from_utf8_lossy(&out.stdout).into_owned(),19 String::from_utf8_lossy(&out.stderr).into_owned(),20 out.status.code().unwrap_or(-1),21 )22 }23 24 fn tmp(tag: &str) -> PathBuf {25 let d =26 std::env::temp_dir().join(format!("f0rge-install-{tag}-{}-{}", std::process::id(), line!()));27 let _ = std::fs::remove_dir_all(&d);28 std::fs::create_dir_all(&d).unwrap();29 d30 }31 32 fn record(dir: &Path, ver: &str, created: &str) {33 let body = format!("---\nversion: {ver}\ncreated: {created}\n---\nnotes {ver}\n");34 std::fs::write(dir.join(".forge/releases").join(format!("{ver}.md")), body).unwrap();35 }36 37 fn record_sha(dir: &Path, ver: &str, created: &str, sha: &str) {38 let body =39 format!("---\nversion: {ver}\ncreated: {created}\nsha: {sha}\n---\nnotes {ver}\n");40 std::fs::write(dir.join(".forge/releases").join(format!("{ver}.md")), body).unwrap();41 }42 43 fn head_short(dir: &Path) -> String {44 let o = Command::new("git").current_dir(dir).args(["rev-parse", "--short", "HEAD"]).output().expect("git rev-parse");45 assert!(o.status.success());46 String::from_utf8(o.stdout).unwrap().trim().to_string()47 }48 49 fn git_init(dir: &Path) {50 git(dir, &["init", "-q"]);51 git(dir, &["config", "user.email", "t@t"]);52 git(dir, &["config", "user.name", "t"]);53 git(dir, &["add", "-A"]);54 git(dir, &["commit", "-qm", "init"]);55 }56 57 fn git(dir: &Path, args: &[&str]) {58 let st = Command::new("git").current_dir(dir).args(args).status().expect("git");59 assert!(st.success(), "git {args:?} failed");60 }61 62 fn cargo_manifest(dir: &Path) {63 std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"f0rge\"\nversion = \"0.0.0\"\n").unwrap();64 }65 66 fn write_sh(dir: &Path, body: &str) {67 let p = dir.join("install.sh");68 std::fs::write(&p, body).unwrap();69 #[cfg(unix)]70 {71 use std::os::unix::fs::PermissionsExt;72 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();73 }74 }75 76 #[test]77 fn install_dry_run_latest_and_main() {78 let dir = tmp("dry");79 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);80 cargo_manifest(&dir);81 record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");82 record(&dir, "v0.1.1", "2026-01-02T00:00:00Z");83 let home = dir.join("home");84 let ch = home.join("cargo-home");85 let dest = ch.join("bin").join("f0rge");86 87 // latest = newest record by (created, version): v0.1.1, cargo project -> compile dry-run.88 let (o, err, code) =89 run_in(&dir, &["release", "install", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);90 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");91 assert!(o.contains("v0.1.1"), "latest must resolve v0.1.1:\n{o}");92 assert!(o.contains("would run:") && o.contains("cargo install"), "dry compile:\n{o}");93 assert_eq!(o.lines().count(), 1, "one line per step:\n{o}");94 assert!(!dest.exists(), "dry-run touches nothing");95 96 // main = current checkout.97 let (o, err, code) =98 run_in(&dir, &["release", "install", "main", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);99 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");100 assert!(o.contains("cargo install") && o.contains("main"), "main dry:\n{o}");101 assert_eq!(o.lines().count(), 1, "one line:\n{o}");102 assert!(!dest.exists(), "dry-run touches nothing");103 104 let _ = std::fs::remove_dir_all(&dir);105 }106 107 #[test]108 fn install_ver_pinned_tag_optional_falls_back() {109 let dir = tmp("pin");110 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);111 cargo_manifest(&dir);112 record(&dir, "v9.9.9", "2026-01-01T00:00:00Z");113 git_init(&dir);114 git(&dir, &["tag", "v9.9.9"]);115 116 let (o, err, code) =117 run_in(&dir, &["release", "install", "v9.9.9", "--dry-run"], &[("HOME", "/nonexistent-x")]);118 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");119 assert!(o.contains("v9.9.9"), "pinned resolves tag:\n{o}");120 121 // unknown version (no record, no tag) -> usage, one line.122 let (o, err, code) = run_in(&dir, &["release", "install", "v0.0.0", "--dry-run"], &[]);123 assert_eq!(code, 2, "stdout:\n{o}\nstderr:\n{err}");124 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");125 assert!(err.contains("no such release: v0.0.0"), "stderr:\n{err}");126 assert_eq!(o, "");127 128 // record exists but tag missing -> falls back to current checkout (#48).129 record(&dir, "v8.8.8", "2026-01-03T00:00:00Z");130 let (o, err, code) = run_in(&dir, &["release", "install", "v8.8.8", "--dry-run"], &[]);131 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");132 assert!(o.contains("v8.8.8") && o.contains("cargo install"), "fallback:\n{o}");133 assert!(o.contains("current checkout"), "labels fallback:\n{o}");134 assert_eq!(o.lines().count(), 1, "one line:\n{o}");135 136 let _ = std::fs::remove_dir_all(&dir);137 }138 139 #[test]140 fn install_sh_handoff_dry_and_real() {141 // #49: CWD project installer owns placement (stale record sha still uses CWD).142 let dir = tmp("sh");143 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);144 git_init(&dir);145 let sha = head_short(&dir);146 record_sha(&dir, "v3.0.0", "2026-01-01T00:00:00Z", &sha);147 // installer added AFTER the record pin (record sha is stale) + committed.148 write_sh(&dir, "#!/bin/sh\necho \"$1 $F0RGE_RELEASE_SHA\" > ran.txt\nexit 0\n");149 git(&dir, &["add", "-A"]);150 git(&dir, &["commit", "-qm", "installer"]);151 152 let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0", "--dry-run"], &[]);153 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");154 assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "dry exec:\n{o}");155 assert_eq!(o.lines().count(), 1, "one line:\n{o}");156 assert!(!dir.join("ran.txt").exists(), "dry-run touches nothing");157 158 let (o, err, code) = run_in(&dir, &["release", "install", "v3.0.0"], &[]);159 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");160 assert!(o.contains("./install.sh") && o.contains("v3.0.0"), "ran:\n{o}");161 let ran = std::fs::read_to_string(dir.join("ran.txt")).unwrap();162 assert!(ran.contains("v3.0.0"), "script got ver:\n{ran}");163 assert!(ran.contains(&sha), "script got pin env:\n{ran}");164 165 let _ = std::fs::remove_dir_all(&dir);166 }167 168 #[test]169 fn install_cargo_only_dry() {170 // #49: cargo project with no install.sh -> cargo path.171 let dir = tmp("cargo");172 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);173 cargo_manifest(&dir);174 assert!(!dir.join("install.sh").exists());175 record(&dir, "v4.0.0", "2026-01-01T00:00:00Z");176 git_init(&dir);177 let (o, err, code) = run_in(&dir, &["release", "install", "v4.0.0", "--dry-run"], &[]);178 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");179 assert!(o.contains("cargo install"), "dry cargo:\n{o}");180 assert_eq!(o.lines().count(), 1, "one line:\n{o}");181 let _ = std::fs::remove_dir_all(&dir);182 }183 184 #[test]185 fn install_no_installer_errors() {186 // #49: neither install.sh nor Cargo.toml -> one line + exit 1.187 let dir = tmp("none");188 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);189 assert!(!dir.join("install.sh").exists());190 assert!(!dir.join("Cargo.toml").exists());191 record(&dir, "v5.0.0", "2026-01-01T00:00:00Z");192 git_init(&dir);193 for args in [&["release", "install", "v5.0.0"][..], &["release", "install", "v5.0.0", "--dry-run"][..]] {194 let (o, err, code) = run_in(&dir, args, &[]);195 assert_eq!(code, 1, "args {args:?} stdout:\n{o}\nstderr:\n{err}");196 assert_eq!(o, "", "stdout empty:\n{o}");197 assert_eq!(err.lines().count(), 1, "one line:\n{err}");198 assert!(err.contains("no installer for this project (install.sh or Cargo.toml)"), "stderr:\n{err}");199 }200 let _ = std::fs::remove_dir_all(&dir);201 }202 203 #[test]204 fn release_step_green_on_temp_repo_with_artifact() {205 let dir = tmp("step");206 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);207 record(&dir, "v0.0.1", "2026-01-01T00:00:00Z");208 // stub cargo: pretend the build produced target/release/f0rge.209 let stub = dir.join("stubbin");210 std::fs::create_dir_all(&stub).unwrap();211 std::fs::write(212 stub.join("cargo"),213 "#!/bin/sh\nmkdir -p target/release\necho stub-f0rge > target/release/f0rge\nchmod +x target/release/f0rge\nexit 0\n",214 )215 .unwrap();216 #[cfg(unix)]217 {218 use std::os::unix::fs::PermissionsExt;219 std::fs::set_permissions(stub.join("cargo"), std::fs::Permissions::from_mode(0o755)).unwrap();220 }221 let step = concat!(222 env!("CARGO_MANIFEST_DIR"),223 "/../../.forge/ci/steps/03_release.sh"224 );225 let path = format!("{}:{}", stub.display(), std::env::var("PATH").unwrap_or_default());226 let st = Command::new("sh")227 .current_dir(&dir)228 .arg(step)229 .env("PATH", &path)230 .env("CARGO_BUILD_JOBS", "2")231 .status()232 .expect("run 03_release.sh");233 assert!(st.success(), "03_release green on temp repo");234 let art = dir.join(".forge/releases/build/v0.0.1/f0rge");235 assert!(art.is_file(), "artifact dropped per record");236 assert_eq!(std::fs::read(&art).unwrap(), b"stub-f0rge\n");237 238 let _ = std::fs::remove_dir_all(&dir);239 }240 241 #[test]242 fn create_rejects_bare_version_with_v_hint() {243 // #47: `create 0.2.0` -> usage error (exit 2), one line, try-path.244 let dir = tmp("vpre");245 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);246 std::fs::write(dir.join("notes.md"), "hi\n").unwrap();247 let (o, err, code) = run_in(&dir, &["release", "create", "0.2.0", "--notes", "notes.md"], &[]);248 assert_eq!(code, 2, "stdout:\n{o}\nstderr:\n{err}");249 assert_eq!(err.lines().count(), 1, "stderr:\n{err}");250 assert!(err.contains("versions use the v prefix: v0.2.0"), "stderr:\n{err}");251 assert!(err.contains("(try: f0rge release create --help)"), "stderr:\n{err}");252 assert_eq!(o, "");253 assert!(!dir.join(".forge/releases/0.2.0.md").exists(), "nothing written");254 let _ = std::fs::remove_dir_all(&dir);255 }256 257 #[test]258 fn create_records_head_sha() {259 // #47: `create v0.9.9` writes the record with sha = HEAD (short).260 let dir = tmp("csha");261 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);262 std::fs::write(dir.join("notes.md"), "hi\n").unwrap();263 git_init(&dir);264 let sha = head_short(&dir);265 let (o, err, code) = run_in(&dir, &["release", "create", "v0.9.9", "--notes", "notes.md"], &[]);266 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");267 let md = std::fs::read_to_string(dir.join(".forge/releases/v0.9.9.md")).unwrap();268 assert!(269 md.contains(&format!("sha: {sha}")) || md.contains(&format!("sha: '{sha}'")),270 "record:\n{md}"271 );272 // old records without sha still parse (view green, no warning).273 record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");274 let (o, err, code) = run_in(&dir, &["release", "view", "v0.1.0"], &[]);275 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");276 let _ = std::fs::remove_dir_all(&dir);277 }278 279 #[test]280 fn install_resolves_record_sha_with_no_tags() {281 // #47/#48: record sha wins with zero git tags present.282 let dir = tmp("rsha");283 assert_eq!(run_in(&dir, &["init"], &[]).2, 0);284 cargo_manifest(&dir);285 git_init(&dir);286 let sha = head_short(&dir);287 record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha);288 let tags = Command::new("git").current_dir(&dir).args(["tag", "-l"]).output().unwrap();289 assert!(String::from_utf8(tags.stdout).unwrap().trim().is_empty(), "no tags");290 let (o, err, code) =291 run_in(&dir, &["release", "install", "v0.9.9", "--dry-run"], &[("HOME", "/nonexistent-x")]);292 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");293 assert!(o.contains("v0.9.9") && o.contains(&sha), "record-sha resolution:\n{o}");294 assert!(o.contains("cargo install"), "compile fallback:\n{o}");295 assert_eq!(o.lines().count(), 1, "one line:\n{o}");296 let _ = std::fs::remove_dir_all(&dir);297 }