merge 1bb6ec4a-f666-4588-b5ec-e78e366829f9

ecdc40agl0w

all commits

Message

merge 1bb6ec4a-f666-4588-b5ec-e78e366829f9

Diff

.forge/wiki/docs/AGENTS.md+8 −7
diff --git a/.forge/wiki/docs/AGENTS.md b/.forge/wiki/docs/AGENTS.mdindex 9e47af0..dd93130 100644--- a/.forge/wiki/docs/AGENTS.md+++ b/.forge/wiki/docs/AGENTS.md@@ -12,18 +12,19 @@ dogfoods: building f0rge is the first f0rge project (from PLAN M3 on).1212 1313 ## Install1414 15-One command, from a clone:15+`f0rge release install` installs THIS project's release artifact:1616 1717 - latest release: `f0rge release install`18-- pinned version: `f0rge release install v0.1.1` (needs the git tag)18+- pinned version: `f0rge release install v0.1.1` (record sha, else git tag, else checkout)1919 - moving tip: `f0rge release install main` (builds current checkout)2020 - preview only: append `--dry-run` (prints each step, changes nothing)2121 22-No binary yet (fresh machine)? `cargo install --locked --path .`23-from the clone, then `f0rge release install` keeps you current.24-A prebuilt `.forge/releases/build/<ver>/f0rge` copies instantly when25-present; otherwise the ref compiles via `cargo install --locked`26-into `$CARGO_HOME/bin` (else `~/.cargo/bin`).22+A lone file under `.forge/releases/build/<ver>/` copies to23+`$CARGO_HOME/bin/<its name>` (any project, any language); otherwise24+the resolved tree compiles via `cargo install --locked` (bin name from25+the manifest). f0rge is the example: it drops26+`.forge/releases/build/<ver>/f0rge`, and a fresh machine starts with27+`cargo install --locked --path .` from the clone.2728 2829 ## Reading order2930 
crates/cli/src/cmd/release.rs+210 −42
diff --git a/crates/cli/src/cmd/release.rs b/crates/cli/src/cmd/release.rsindex e073629..f05aadc 100644--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -5,8 +5,8 @@55 //! outputs into releases/out/ (gitignored). No binaries in git.66 77 use crate::{args, output, CliError};8-use f0rge_core::{Forge, ForgeError, Release};9-use serde::Serialize;8+use f0rge_core::{Forge, ForgeError};9+use serde::{Deserialize, Serialize};1010 use std::path::{Component, Path, PathBuf};1111 use std::process::Command;1212 use std::sync::atomic::{AtomicU64, Ordering};@@ -53,6 +53,59 @@ fn clean(s: &str) -> String {5353     s.chars().filter(|c| *c != '\n' && *c != '\r').take(120).collect::<String>().trim().to_string()5454 }5555 56+/// Release record (#47): `sha` is HEAD (short) at create time;57+/// absent on old records and non-git trees. Local mirror of the58+/// core Release + optional sha, so core stays untouched.59+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]60+struct ReleaseRec {61+    version: String,62+    #[serde(default, skip_serializing_if = "Option::is_none")]63+    title: Option<String>,64+    created: String,65+    #[serde(default, skip_serializing_if = "Option::is_none")]66+    sha: Option<String>,67+}68+69+impl f0rge_core::Validate for ReleaseRec {70+    const KNOWN: &'static [&'static str] =71+        &["version", "title", "created", "sha", "schema"];72+    fn validate(&self, file: &str) -> Result<(), ForgeError> {73+        if !f0rge_core::is_rfc3339_utc(&self.created) {74+            return Err(ForgeError::BadValue { file: file.to_string(), field: "created".to_string() });75+        }76+        Ok(())77+    }78+}79+80+/// HEAD short sha via argv-only git; None outside git (#47).81+fn head_sha(repo: &Path) -> Option<String> {82+    let o = Command::new("git")83+        .arg("-C")84+        .arg(repo)85+        .args(["rev-parse", "--short", "HEAD"])86+        .output()87+        .ok()?;88+    if !o.status.success() {89+        return None;90+    }91+    let s = String::from_utf8(o.stdout).ok()?.trim().to_string();92+    if s.is_empty() || s.len() > 64 || !s.chars().all(|c| c.is_ascii_alphanumeric()) {93+        return None;94+    }95+    Some(s)96+}97+98+/// Record sha when it looks like a git sha (else fall through).99+fn good_sha(s: &str) -> bool {100+    !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric())101+}102+103+fn record_sha(fg: &Forge, ver: &str) -> Option<String> {104+    let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&fg.release_file(ver)).ok()?;105+    let s = p.value.sha?;106+    if good_sha(&s) { Some(s) } else { None }107+}108+56109 /// Version becomes `<ver>.md`; slashes/escapes -> Runtime (exit 1).57110 fn check_ver(raw: &str) -> Result<String, CliError> {58111     let v = clean(raw);@@ -71,6 +124,15 @@ fn check_ver(raw: &str) -> Result<String, CliError> {71124     Ok(v)72125 }73126 127+/// Create enforces the v prefix (#47): bare `0.2.0` -> usage (exit 2).128+fn check_ver_create(raw: &str) -> Result<String, CliError> {129+    let v = check_ver(raw)?;130+    if v.starts_with('v') {131+        return Ok(v);132+    }133+    Err(usage(&format!("versions use the v prefix: v{v}")))134+}135+74136 fn norm_body(b: &str) -> String {75137     if b.is_empty() {76138         return String::new();@@ -218,21 +280,21 @@ fn git(repo: &Path, argv: &[&str]) -> bool {218280         .unwrap_or(false)219281 }220282 221-/// Checkout <ver> tag into a temp worktree; None when no git/no tag.222-fn try_worktree(repo: &Path, ver: &str) -> Option<PathBuf> {223-    if !git(repo, &["rev-parse", "--verify", ver]) {283+/// Checkout a tag or sha into a temp worktree; None when no git/no ref.284+fn try_worktree(repo: &Path, gitref: &str, label: &str) -> Option<PathBuf> {285+    if !git(repo, &["rev-parse", "--verify", gitref]) {224286         return None;225287     }226288     let n = BUILD_CTR.fetch_add(1, Ordering::Relaxed);227289     let tmp =228-        std::env::temp_dir().join(format!("f0rge-rel-{}-{}-{n}", std::process::id(), ver));290+        std::env::temp_dir().join(format!("f0rge-rel-{}-{}-{n}", std::process::id(), label));229291     let _ = std::fs::remove_dir_all(&tmp);230292     let ok = Command::new("git")231293         .arg("-C")232294         .arg(repo)233295         .args(["worktree", "add", "--detach"])234296         .arg(&tmp)235-        .arg(ver)297+        .arg(gitref)236298         .stdout(std::process::Stdio::null())237299         .stderr(std::process::Stdio::null())238300         .status()@@ -289,7 +351,7 @@ fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {289351     }290352     rows.sort();291353     let rel = fg.release_file(ver);292-    let p: f0rge_core::Parsed<Release> = fg.load_entity(&rel).map_err(CliError::from)?;354+    let p: f0rge_core::Parsed<ReleaseRec> = fg.load_entity(&rel).map_err(CliError::from)?;293355     warn(&format!("{ver}.md"), &p.warnings, &p.schema_note);294356     let mut body = p.body;295357     if !body.is_empty() && !body.ends_with('\n') {@@ -310,7 +372,7 @@ fn run_build(fg: &Forge, ver: &str) -> Result<(), CliError> {310372         None => return Ok(()),311373     };312374     let out = repo.join(".forge").join("releases").join("out").join(ver);313-    if let Some(wt) = try_worktree(&repo, ver) {375+    if let Some(wt) = try_worktree(&repo, ver, ver) {314376         let scripts = list_scripts(&wt.join(".forge").join("releases").join("build"));315377         let r = if scripts.is_empty() {316378             Ok(())@@ -338,19 +400,22 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {338400     if fs.positional.len() != 1 {339401         return Err(usage("f0rge release create <ver> --notes <file|->"));340402     }341-    let ver = check_ver(&fs.positional[0].clone())?;403+    let ver = check_ver_create(&fs.positional[0].clone())?;342404     let src = fs.get("notes").ok_or_else(|| usage("f0rge release create <ver> --notes <file|->"))?;343405     let notes = read_notes(src)?;344-    let rel = Release {406+    let repo = fg.root().parent().map(|p| p.to_path_buf());407+    let sha = repo.as_ref().and_then(|r| head_sha(r));408+    let rel = ReleaseRec {345409         version: ver.clone(),346410         title: fs.get("title").map(|s| clean(s)).filter(|s| !s.is_empty()),347411         created: f0rge_core::now_rfc3339(),412+        sha,348413     };349414     fg.save_entity(&fg.release_file(&ver), &rel, &notes)?;350415     run_build(fg, &ver)?; // side effect; notes already saved351416     append_hashes(fg, &ver)?; // no-op when out/ is missing or empty352417     let final_body: String = fg353-        .load_entity::<Release>(&fg.release_file(&ver))418+        .load_entity::<ReleaseRec>(&fg.release_file(&ver))354419         .map(|p| p.body)355420         .unwrap_or(notes);356421     output::emit_json(@@ -393,7 +458,7 @@ fn load_all(fg: &Forge) -> Vec<Row> {393458             continue;394459         }395460         let rel = Path::new("releases").join(&name);396-        match fg.load_entity::<Release>(&rel) {461+        match fg.load_entity::<ReleaseRec>(&rel) {397462             Ok(p) => {398463                 warn(&name, &p.warnings, &p.schema_note);399464                 out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })@@ -441,7 +506,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {441506 442507 fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {443508     let ver = check_ver(raw)?;444-    let p: f0rge_core::Parsed<Release> =509+    let p: f0rge_core::Parsed<ReleaseRec> =445510         fg.load_entity(&fg.release_file(&ver)).map_err(|e| match e {446511             ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),447512             _ => CliError::from(e),@@ -479,15 +544,15 @@ struct InstallJson {479544     method: String,480545 }481546 482-/// Install destination: $CARGO_HOME/bin/f0rge, else ~/.cargo/bin/f0rge.483-fn cargo_dest() -> Result<PathBuf, CliError> {547+/// Install destination: $CARGO_HOME/bin/<name>, else ~/.cargo/bin/<name>.548+fn cargo_dest_for(name: &str) -> Result<PathBuf, CliError> {484549     if let Ok(h) = std::env::var("CARGO_HOME") {485550         if !h.is_empty() {486-            return Ok(PathBuf::from(h).join("bin").join("f0rge"));551+            return Ok(PathBuf::from(h).join("bin").join(name));487552         }488553     }489554     match std::env::var("HOME") {490-        Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join("f0rge")),555+        Ok(h) if !h.is_empty() => Ok(PathBuf::from(h).join(".cargo").join("bin").join(name)),491556         _ => Err(CliError::Runtime("no home".to_string())),492557     }493558 }@@ -527,8 +592,84 @@ fn cargo_install_from(src: &Path) -> Result<(), CliError> {527592     }528593 }529594 530-/// Pinned/tag install: fast copy of releases/build/<ver>/f0rge, else531-/// compile from a temp tag checkout (argv-only, worktree cleaned up).595+/// Lone prebuilt file under releases/build/<ver>/ (any name, any596+/// language); Some(path) only when exactly one file sits there (#48).597+fn single_artifact(dir: &Path) -> Option<PathBuf> {598+    let rd = std::fs::read_dir(dir).ok()?;599+    let mut files = Vec::new();600+    for e in rd.flatten() {601+        let ft = match e.file_type() {602+            Ok(t) => t,603+            Err(_) => continue,604+        };605+        if ft.is_symlink() || !ft.is_file() {606+            continue;607+        }608+        files.push(e.path());609+    }610+    if files.len() == 1 { files.into_iter().next() } else { None }611+}612+613+/// Bin name from a Cargo manifest: first [[bin]].name, else614+/// [package].name. Textual scan (no new deps); None when absent.615+fn manifest_bin_name(dir: &Path) -> Option<String> {616+    let text = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;617+    if text.len() > 65536 {618+        return None;619+    }620+    let mut pkg: Option<String> = None;621+    let mut section = String::new();622+    for line in text.lines() {623+        let t = line.trim();624+        if t.starts_with('[') && t.ends_with(']') {625+            section = t.to_string();626+            continue;627+        }628+        if section != "[package]" && section != "[[bin]]" {629+            continue;630+        }631+        let rest = match t.split_once('=') {632+            Some((k, v)) if k.trim() == "name" => v.trim(),633+            _ => continue,634+        };635+        if !rest.starts_with('"') {636+            continue;637+        }638+        let end = rest[1..].find('"')?;639+        let name = rest[1..1 + end].to_string();640+        if name.is_empty() {641+            continue;642+        }643+        if section == "[[bin]]" {644+            return Some(name);645+        }646+        if pkg.is_none() {647+            pkg = Some(name);648+        }649+    }650+    pkg651+}652+653+/// Path-safe bin name for the cargo fallback; f0rge when the654+/// manifest is missing or the name is not a safe filename (#48).655+fn safe_bin(dir: &Path) -> String {656+    match manifest_bin_name(dir) {657+        Some(n)658+            if !n.is_empty()659+                && n.len() <= 64660+                && n.chars().all(|c| {661+                    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'662+                }) =>663+        {664+            n665+        }666+        _ => "f0rge".to_string(),667+    }668+}669+670+/// Pinned install (#48): a lone build artifact copies to671+/// $CARGO_HOME/bin/<its name>; else cargo install from the resolved672+/// tree (record sha > git tag > current checkout). Argv-only throughout.532673 fn install_ver(533674     fs: &args::FlagSet,534675     fg: &Forge,@@ -536,17 +677,21 @@ fn install_ver(536677     ver: &str,537678     dry: bool,538679 ) -> Result<(), CliError> {539-    let dest = cargo_dest()?;540-    let artifact = fg.root().join("releases").join("build").join(ver).join("f0rge");541-    if artifact.is_file() {680+    let build_dir = fg.root().join("releases").join("build").join(ver);681+    if let Some(art) = single_artifact(&build_dir) {682+        let name = art683+            .file_name()684+            .map(|s| s.to_string_lossy().into_owned())685+            .unwrap_or_else(|| "f0rge".to_string());686+        let dest = cargo_dest_for(&name)?;542687         if dry {543-            output::out(&format!("would copy {} to {}\n", artifact.display(), dest.display()));688+            output::out(&format!("would copy {} to {}\n", art.display(), dest.display()));544689             return Ok(());545690         }546691         if let Some(p) = dest.parent() {547692             let _ = std::fs::create_dir_all(p);548693         }549-        std::fs::copy(&artifact, &dest)694+        std::fs::copy(&art, &dest)550695             .map_err(|_| CliError::Runtime("install failed".to_string()))?;551696         #[cfg(unix)]552697         {@@ -558,24 +703,50 @@ fn install_ver(558703         emit_installed(fs, &dest, &sha, ver, "copy");559704         return Ok(());560705     }706+    let sha = record_sha(fg, ver);707+    let tag_ok = tag_exists(repo, ver);708+    let sha_ok = sha.as_ref().is_some_and(|s| tag_exists(repo, s));709+    let (at, label) = if sha_ok {710+        let s = sha.clone().unwrap_or_default();711+        (Some(s.clone()), format!("{ver} @ {s}"))712+    } else if tag_ok {713+        (Some(ver.to_string()), ver.to_string())714+    } else {715+        (None, format!("{ver} (current checkout)"))716+    };717+    let dest = cargo_dest_for(&safe_bin(repo))?;561718     if dry {562719         output::out(&format!(563-            "would run: cargo install --locked --path <checkout of {ver}> to {}\n",720+            "would run: cargo install --locked --path <checkout of {label}> to {}\n",564721             dest.display()565722         ));566723         return Ok(());567724     }568-    match try_worktree(repo, ver) {569-        Some(wt) => {570-            let r = cargo_install_from(&wt);571-            cleanup_worktree(repo, &wt);572-            r?;725+    match at {726+        Some(r) => match try_worktree(repo, &r, ver) {727+            Some(wt) => {728+                let res = cargo_install_from(&wt);729+                let dest = cargo_dest_for(&safe_bin(&wt)).unwrap_or(dest);730+                cleanup_worktree(repo, &wt);731+                res?;732+                let sha = sha256_of(&dest).unwrap_or_default();733+                emit_installed(fs, &dest, &sha, ver, "compile");734+                Ok(())735+            }736+            None => {737+                cargo_install_from(repo)?;738+                let sha = sha256_of(&dest).unwrap_or_default();739+                emit_installed(fs, &dest, &sha, ver, "compile");740+                Ok(())741+            }742+        },743+        None => {744+            cargo_install_from(repo)?;745+            let sha = sha256_of(&dest).unwrap_or_default();746+            emit_installed(fs, &dest, &sha, ver, "compile");747+            Ok(())573748         }574-        None => cargo_install_from(repo)?,575749     }576-    let sha = sha256_of(&dest).unwrap_or_default();577-    emit_installed(fs, &dest, &sha, ver, "compile");578-    Ok(())579750 }580751 581752 /// Main install: compile the current checkout in place.@@ -586,7 +757,7 @@ fn install_main(586757     dry: bool,587758 ) -> Result<(), CliError> {588759     let _ = fg;589-    let dest = cargo_dest()?;760+    let dest = cargo_dest_for(&safe_bin(repo))?;590761     if dry {591762         output::out(&format!(592763             "would run: cargo install --locked --path {} (main) to {}\n",@@ -601,8 +772,8 @@ fn install_main(601772     Ok(())602773 }603774 604-/// `install` = latest record; `install <ver>` = pinned tag (tag must605-/// exist, else usage `no such release: <ver>`); `install main` = cwd.775+/// `install` = latest record; `install <ver>` = pinned record (record776+/// sha > git tag > current checkout); `install main` = cwd.606777 fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), CliError> {607778     let repo =608779         fg.root().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));@@ -611,10 +782,7 @@ fn install(fs: &args::FlagSet, fg: &Forge, raw: Option<String>) -> Result<(), Cl611782         Some(r) if clean(&r) == "main" => install_main(fs, fg, &repo, dry),612783         Some(r) => {613784             let ver = check_ver(&r)?;614-            if fg.load_entity::<Release>(&fg.release_file(&ver)).is_err() {615-                return Err(CliError::Usage(format!("no such release: {ver}")));616-            }617-            if !tag_exists(&repo, &ver) {785+            if fg.load_entity::<ReleaseRec>(&fg.release_file(&ver)).is_err() {618786                 return Err(CliError::Usage(format!("no such release: {ver}")));619787             }620788             install_ver(fs, fg, &repo, &ver, dry)
crates/cli/tests/install.rs+115 −10
diff --git a/crates/cli/tests/install.rs b/crates/cli/tests/install.rsindex 9c37105..264ce83 100644--- a/crates/cli/tests/install.rs+++ b/crates/cli/tests/install.rs@@ -34,6 +34,26 @@ fn record(dir: &Path, ver: &str, created: &str) {3434     std::fs::write(dir.join(".forge/releases").join(format!("{ver}.md")), body).unwrap();3535 }3636 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+3757 fn git(dir: &Path, args: &[&str]) {3858     let st = Command::new("git").current_dir(dir).args(args).status().expect("git");3959     assert!(st.success(), "git {args:?} failed");@@ -70,15 +90,11 @@ fn install_dry_run_latest_and_main() {7090 }7191 7292 #[test]73-fn install_ver_pinned_needs_tag() {93+fn install_ver_pinned_tag_optional_falls_back() {7494     let dir = tmp("pin");7595     assert_eq!(run_in(&dir, &["init"], &[]).2, 0);7696     record(&dir, "v9.9.9", "2026-01-01T00:00:00Z");77-    git(&dir, &["init", "-q"]);78-    git(&dir, &["config", "user.email", "t@t"]);79-    git(&dir, &["config", "user.name", "t"]);80-    git(&dir, &["add", "-A"]);81-    git(&dir, &["commit", "-qm", "init"]);97+    git_init(&dir);8298     git(&dir, &["tag", "v9.9.9"]);8399 84100     let (o, err, code) =@@ -93,11 +109,13 @@ fn install_ver_pinned_needs_tag() {93109     assert!(err.contains("no such release: v0.0.0"), "stderr:\n{err}");94110     assert_eq!(o, "");95111 96-    // record exists but tag missing -> same usage error.112+    // record exists but tag missing -> falls back to current checkout (#48).97113     record(&dir, "v8.8.8", "2026-01-03T00:00:00Z");98-    let (_, err, code) = run_in(&dir, &["release", "install", "v8.8.8", "--dry-run"], &[]);99-    assert_eq!(code, 2);100-    assert!(err.contains("no such release: v8.8.8"), "stderr:\n{err}");114+    let (o, err, code) = run_in(&dir, &["release", "install", "v8.8.8", "--dry-run"], &[]);115+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");116+    assert!(o.contains("v8.8.8") && o.contains("cargo install"), "fallback:\n{o}");117+    assert!(o.contains("current checkout"), "labels fallback:\n{o}");118+    assert_eq!(o.lines().count(), 1, "one line:\n{o}");101119 102120     let _ = std::fs::remove_dir_all(&dir);103121 }@@ -167,3 +185,90 @@ fn release_step_green_on_temp_repo_with_artifact() {167185 168186     let _ = std::fs::remove_dir_all(&dir);169187 }188+189+#[test]190+fn create_rejects_bare_version_with_v_hint() {191+    // #47: `create 0.2.0` -> usage error (exit 2), one line, try-path.192+    let dir = tmp("vpre");193+    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);194+    std::fs::write(dir.join("notes.md"), "hi\n").unwrap();195+    let (o, err, code) = run_in(&dir, &["release", "create", "0.2.0", "--notes", "notes.md"], &[]);196+    assert_eq!(code, 2, "stdout:\n{o}\nstderr:\n{err}");197+    assert_eq!(err.lines().count(), 1, "stderr:\n{err}");198+    assert!(err.contains("versions use the v prefix: v0.2.0"), "stderr:\n{err}");199+    assert!(err.contains("(try: f0rge release create --help)"), "stderr:\n{err}");200+    assert_eq!(o, "");201+    assert!(!dir.join(".forge/releases/0.2.0.md").exists(), "nothing written");202+    let _ = std::fs::remove_dir_all(&dir);203+}204+205+#[test]206+fn create_records_head_sha() {207+    // #47: `create v0.9.9` writes the record with sha = HEAD (short).208+    let dir = tmp("csha");209+    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);210+    std::fs::write(dir.join("notes.md"), "hi\n").unwrap();211+    git_init(&dir);212+    let sha = head_short(&dir);213+    let (o, err, code) = run_in(&dir, &["release", "create", "v0.9.9", "--notes", "notes.md"], &[]);214+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");215+    let md = std::fs::read_to_string(dir.join(".forge/releases/v0.9.9.md")).unwrap();216+    assert!(md.contains(&format!("sha: {sha}")), "record:\n{md}");217+    // old records without sha still parse (view green, no warning).218+    record(&dir, "v0.1.0", "2026-01-01T00:00:00Z");219+    let (o, err, code) = run_in(&dir, &["release", "view", "v0.1.0"], &[]);220+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");221+    let _ = std::fs::remove_dir_all(&dir);222+}223+224+#[test]225+fn install_resolves_record_sha_with_no_tags() {226+    // #47/#48: record sha wins with zero git tags present.227+    let dir = tmp("rsha");228+    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);229+    git_init(&dir);230+    let sha = head_short(&dir);231+    record_sha(&dir, "v0.9.9", "2026-01-01T00:00:00Z", &sha);232+    let tags = Command::new("git").current_dir(&dir).args(["tag", "-l"]).output().unwrap();233+    assert!(String::from_utf8(tags.stdout).unwrap().trim().is_empty(), "no tags");234+    let (o, err, code) =235+        run_in(&dir, &["release", "install", "v0.9.9", "--dry-run"], &[("HOME", "/nonexistent-x")]);236+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");237+    assert!(o.contains("v0.9.9") && o.contains(&sha), "record-sha resolution:\n{o}");238+    assert!(o.contains("cargo install"), "compile fallback:\n{o}");239+    assert_eq!(o.lines().count(), 1, "one line:\n{o}");240+    let _ = std::fs::remove_dir_all(&dir);241+}242+243+#[test]244+fn install_copies_lone_artifact_any_name() {245+    // #48: non-cargo project, one file under build/<ver>/ -> COPY it.246+    let dir = tmp("lone");247+    assert_eq!(run_in(&dir, &["init"], &[]).2, 0);248+    assert!(!dir.join("Cargo.toml").exists());249+    record(&dir, "v2.0.0", "2026-01-01T00:00:00Z");250+    let art = dir.join(".forge/releases/build/v2.0.0/onefile");251+    std::fs::create_dir_all(art.parent().unwrap()).unwrap();252+    std::fs::write(&art, b"#!/bin/sh\necho hi\n").unwrap();253+254+    let ch = dir.join("chome");255+    let dest = ch.join("bin").join("onefile");256+    let (o, err, code) =257+        run_in(&dir, &["release", "install", "v2.0.0", "--dry-run"], &[("CARGO_HOME", &ch.to_string_lossy())]);258+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");259+    assert!(o.contains("would copy") && o.contains("onefile"), "dry copy:\n{o}");260+    assert!(o.contains(&dest.display().to_string()), "dry dest:\n{o}");261+    assert!(!dest.exists(), "dry-run touches nothing");262+263+    let (o, err, code) =264+        run_in(&dir, &["release", "install", "v2.0.0"], &[("CARGO_HOME", &ch.to_string_lossy())]);265+    assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");266+    assert_eq!(std::fs::read(&dest).unwrap(), b"#!/bin/sh\necho hi\n");267+    assert!(o.contains("installed") && o.contains("v2.0.0"), "prints path+sha+ref:\n{o}");268+    #[cfg(unix)]269+    {270+        use std::os::unix::fs::PermissionsExt;271+        assert!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o111 != 0, "chmod +x");272+    }273+    let _ = std::fs::remove_dir_all(&dir);274+}
spec/fixtures/release.md+1 −0
diff --git a/spec/fixtures/release.md b/spec/fixtures/release.mdindex 992e874..631eb38 100644--- a/spec/fixtures/release.md+++ b/spec/fixtures/release.md@@ -2,6 +2,7 @@22 version: v0.1.033 title: "First forge release"44 created: 2025-09-08T06:00:00Z5+sha: abc123456 ---67 ## Notes78 - Schema v1 frozen.
spec/schema.md+1 −1
diff --git a/spec/schema.md b/spec/schema.mdindex 5d878b4..09dad17 100644--- a/spec/schema.md+++ b/spec/schema.md@@ -79,7 +79,7 @@ Required: `name`. Optional: `state` (open),7979 `due`, `description`. Golden: `spec/fixtures/milestones.md`.8080 8181 ### release — `.forge/releases/<version>.md`82-Required: `version`, `created`. Optional: `title`.82+Required: `version`, `created`. Optional: `title`, `sha` (HEAD short sha at create time; absent on old records).8383 Body = notes. Verbs: `create <ver> --notes <file|-> [--title S]`,8484 `install [ver|main] [--dry-run]` (list/view unchanged). Golden: `spec/fixtures/release.md`.8585