Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

311 linesCodeBlameRaw
1//! `g1t upgrade`: the CLI updates itself to the newest release, and says
2//! now and then, in a terminal, that a newer one is out.
3//!
4//! Releases are at `https://g1t.sh/downloads/cli/` (`G1T_DOWNLOAD_URL`
5//! names another, as for the install scripts): `latest.json`, the newest
6//! release's manifest, and `latest.json.sig`, its Ed25519 signature with
7//! g1t's release key (scripts/cli-release.mjs). The CLI trusts a manifest
8//! only when the signature checks out against the key built into it
9//! (`G1T_RUNNER_RELEASE_KEY` when it was built), and installs a download
10//! only when its SHA-256 is the manifest's. A build without the key, such
11//! as one built from source, cannot upgrade itself and says how to.
12
13use std::io::IsTerminal;
14use std::path::PathBuf;
15use std::sync::mpsc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use crate::cli::UpgradeArgs;
24use crate::error::{Error, Result};
25
26/// This build's version.
27pub const VERSION: &str = env!("CARGO_PKG_VERSION");
28/// g1t's release key, as base64 of its 32 bytes: set when release builds
29/// are made.
30const RELEASE_KEY: Option<&str> = option_env!("G1T_RUNNER_RELEASE_KEY");
31/// The install scripts, for a build that cannot upgrade itself.
32const INSTALL: &str = "curl -fsSL https://g1t.sh/install | sh (on Windows: irm https://g1t.sh/install.ps1 | iex)";
33/// How often the CLI looks for a newer release on its own.
34const CHECK_EVERY: Duration = Duration::from_secs(24 * 60 * 60);
35/// How long a command's end waits for that look before leaving it.
36const NOTICE_WAIT: Duration = Duration::from_millis(400);
37
38#[derive(Clone, Debug, Deserialize)]
39pub struct File {
40 pub name: String,
41 pub sha256: String,
42}
43
44#[derive(Clone, Debug, Deserialize)]
45pub struct Manifest {
46 pub version: String,
47 /// By platform: `linux-x64`, `macos-arm64`, `windows-x64`…
48 pub files: std::collections::BTreeMap<String, File>,
49}
50
51/// Where the CLI's releases are.
52fn downloads() -> String {
53 std::env::var("G1T_DOWNLOAD_URL")
54 .ok()
55 .filter(|url| !url.trim().is_empty())
56 .unwrap_or_else(|| "https://g1t.sh/downloads/cli".to_owned())
57 .trim_end_matches('/')
58 .to_owned()
59}
60
61/// This machine's platform, as releases name it. Windows on ARM runs the x64 build.
62pub fn platform() -> String {
63 let os = match std::env::consts::OS {
64 "macos" => "macos",
65 "windows" => "windows",
66 _ => "linux",
67 };
68 let arch = if std::env::consts::ARCH == "aarch64" && os != "windows" { "arm64" } else { "x64" };
69 format!("{os}-{arch}")
70}
71
72/// `a.b.c` newer than `x.y.z`.
73pub fn newer(candidate: &str, current: &str) -> bool {
74 let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() };
75 parts(candidate) > parts(current)
76}
77
78pub fn sha256(bytes: &[u8]) -> String {
79 hex::encode(Sha256::digest(bytes))
80}
81
82/// Whether `signature` (base64) is the release key's over `message`.
83pub fn verify(message: &[u8], signature: &str, key: &str) -> std::result::Result<(), String> {
84 let key: [u8; 32] = STANDARD
85 .decode(key.trim())
86 .ok()
87 .and_then(|bytes| bytes.try_into().ok())
88 .ok_or("the release key built into this g1t does not read")?;
89 let signature: [u8; 64] = STANDARD
90 .decode(signature.trim())
91 .ok()
92 .and_then(|bytes| bytes.try_into().ok())
93 .ok_or("the release's signature does not read")?;
94 let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| "the release key is not a key")?;
95 key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature))
96 .map_err(|_| "the release's signature is not g1t's".to_owned())
97}
98
99fn agent(timeout: Duration) -> ureq::Agent {
100 ureq::AgentBuilder::new().timeout(timeout).user_agent(&format!("g1t/{VERSION}")).build()
101}
102
103fn download(agent: &ureq::Agent, url: &str) -> std::result::Result<Vec<u8>, String> {
104 let response = agent.get(url).call().map_err(|error| format!("could not download {url}: {error}"))?;
105 let mut bytes = Vec::new();
106 std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes).map_err(|error| format!("could not download {url}: {error}"))?;
107 Ok(bytes)
108}
109
110/// The newest release, its signature checked with `key`.
111fn latest(agent: &ureq::Agent, key: &str) -> std::result::Result<Manifest, String> {
112 let base = downloads();
113 let manifest = download(agent, &format!("{base}/latest.json"))?;
114 let signature = String::from_utf8(download(agent, &format!("{base}/latest.json.sig"))?).map_err(|_| "the release's signature does not read")?;
115 verify(&manifest, &signature, key)?;
116 serde_json::from_slice(&manifest).map_err(|error| format!("the release's manifest does not read: {error}"))
117}
118
119/// Puts `bytes` where this program is. The running file is moved aside
120/// first: Windows will not overwrite a running program, but lets it be
121/// renamed. The old one is removed next time, when it no longer runs.
122fn replace_self(bytes: &[u8]) -> std::result::Result<PathBuf, String> {
123 let me = std::env::current_exe().map_err(|error| format!("could not find this program: {error}"))?;
124 // A link to it (Homebrew-style) is followed to the file; Windows has none, and
125 // canonicalizing there only adds a `\\?\` prefix.
126 #[cfg(unix)]
127 let me = std::fs::canonicalize(&me).unwrap_or(me);
128 let new = me.with_extension("new");
129 let old = me.with_extension("old");
130 std::fs::write(&new, bytes).map_err(|error| format!("could not write {}: {error}", new.display()))?;
131 #[cfg(unix)]
132 {
133 use std::os::unix::fs::PermissionsExt;
134 std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|error| error.to_string())?;
135 }
136 let _ = std::fs::remove_file(&old);
137 if let Err(error) = std::fs::rename(&me, &old) {
138 let _ = std::fs::remove_file(&new);
139 return Err(format!("could not move {} aside: {error}", me.display()));
140 }
141 if let Err(error) = std::fs::rename(&new, &me) {
142 let _ = std::fs::rename(&old, &me);
143 return Err(format!("could not put the new version at {}: {error}", me.display()));
144 }
145 Ok(me)
146}
147
148/// What an earlier upgrade left: the version it replaced, once it no
149/// longer runs.
150fn tidy_old() {
151 if let Ok(me) = std::env::current_exe() {
152 let _ = std::fs::remove_file(me.with_extension("old"));
153 }
154}
155
156/// `g1t upgrade`.
157pub fn run(args: UpgradeArgs) -> Result<()> {
158 let Some(key) = RELEASE_KEY else {
159 return Err(Error::failed(format!(
160 "this build of g1t ({VERSION}) was not made by a g1t release, so it cannot check releases. Install the newest one with: {INSTALL}"
161 )));
162 };
163 let agent = agent(Duration::from_secs(120));
164 let latest = latest(&agent, key).map_err(Error::failed)?;
165 remember(&latest.version);
166 if !newer(&latest.version, VERSION) {
167 println!("g1t {VERSION} is the newest release.");
168 return Ok(());
169 }
170 if args.check {
171 println!("g1t {} is out; this is {VERSION}. Run `g1t upgrade` to install it.", latest.version);
172 return Ok(());
173 }
174 let file = latest
175 .files
176 .get(&platform())
177 .ok_or_else(|| Error::failed(format!("g1t {} has no build for {}.", latest.version, platform())))?;
178 eprintln!("Downloading g1t {} ({})…", latest.version, platform());
179 let bytes = download(&agent, &format!("{}/{}/{}", downloads(), latest.version, file.name)).map_err(Error::failed)?;
180 let have = sha256(&bytes);
181 if have != file.sha256 {
182 return Err(Error::failed(format!("the download's SHA-256 is {have}, not the release's {}. Nothing was changed.", file.sha256)));
183 }
184 let at = replace_self(&bytes).map_err(Error::failed)?;
185 println!("Upgraded g1t {VERSION} to {} at {}.", latest.version, at.display());
186 Ok(())
187}
188
189// --- Saying a newer release is out -------------------------------------------
190
191#[derive(Default, Serialize, Deserialize)]
192struct Seen {
193 /// Seconds since the epoch.
194 checked_at: u64,
195 version: String,
196}
197
198fn seen_path() -> Option<PathBuf> {
199 crate::config::dir().ok().map(|dir| dir.join("latest-release.json"))
200}
201
202fn now() -> u64 {
203 SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
204}
205
206fn remember(version: &str) {
207 if let Some(path) = seen_path() {
208 let _ = std::fs::create_dir_all(path.parent().unwrap_or(&path));
209 let seen = Seen { checked_at: now(), version: version.to_owned() };
210 let _ = std::fs::write(path, serde_json::to_vec(&seen).unwrap_or_default());
211 }
212}
213
214/// The line that says a newer release is out, if one is.
215pub fn notice_for(newest: &str, current: &str) -> Option<String> {
216 newer(newest, current).then(|| format!("g1t {newest} is out; this is {current}. Run `g1t upgrade` to install it."))
217}
218
219/// Looks for a newer release at most once a day, in the background, for
220/// people at a terminal; [`Notice::finish`] says so after the command. Off
221/// in CI, with `G1T_NO_UPDATE_NOTIFIER=1`, for `g1t upgrade` itself, and
222/// in a build that cannot upgrade itself.
223pub struct Notice {
224 known: Option<String>,
225 looking: Option<mpsc::Receiver<String>>,
226}
227
228impl Notice {
229 pub fn start(upgrading: bool) -> Notice {
230 tidy_old();
231 let off = Notice { known: None, looking: None };
232 let Some(key) = RELEASE_KEY else { return off };
233 let quiet = |name: &str| std::env::var(name).is_ok_and(|value| !value.is_empty() && value != "0");
234 if upgrading || quiet("G1T_NO_UPDATE_NOTIFIER") || quiet("CI") || !std::io::stderr().is_terminal() {
235 return off;
236 }
237 let seen: Seen = seen_path()
238 .and_then(|path| std::fs::read(path).ok())
239 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
240 .unwrap_or_default();
241 let known = (!seen.version.is_empty()).then(|| seen.version.clone());
242 if now().saturating_sub(seen.checked_at) < CHECK_EVERY.as_secs() {
243 return Notice { known, looking: None };
244 }
245 let (tell, looking) = mpsc::channel();
246 std::thread::spawn(move || {
247 if let Ok(latest) = latest(&agent(Duration::from_secs(5)), key) {
248 remember(&latest.version);
249 let _ = tell.send(latest.version);
250 }
251 });
252 Notice { known, looking: Some(looking) }
253 }
254
255 /// After the command: says so when a newer release is out.
256 pub fn finish(self) {
257 let newest = self.looking.and_then(|looking| looking.recv_timeout(NOTICE_WAIT).ok()).or(self.known);
258 if let Some(line) = newest.and_then(|newest| notice_for(&newest, VERSION)) {
259 eprintln!("\n{line}");
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use ed25519_dalek::Signer;
268
269 #[test]
270 fn versions_compare_as_numbers() {
271 assert!(newer("0.10.0", "0.9.9"));
272 assert!(newer("v1.0.0", "0.9.0"));
273 assert!(!newer("0.3.3", "0.3.3"));
274 assert!(!newer("0.3.2", "0.3.3"));
275 assert_eq!(notice_for("0.3.2", "0.3.3"), None);
276 assert_eq!(notice_for("0.4.0", "0.3.3").as_deref(), Some("g1t 0.4.0 is out; this is 0.3.3. Run `g1t upgrade` to install it."));
277 }
278
279 #[test]
280 fn only_the_release_keys_signature_is_trusted() {
281 let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
282 let key = STANDARD.encode(signing.verifying_key().to_bytes());
283 let manifest = br#"{"version":"0.4.0","files":{}}"#;
284 let signature = STANDARD.encode(signing.sign(manifest).to_bytes());
285 assert!(verify(manifest, &signature, &key).is_ok());
286 assert!(verify(br#"{"version":"9.9.9","files":{}}"#, &signature, &key).is_err());
287 let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes());
288 assert!(verify(manifest, &signature, &other).is_err());
289 assert!(verify(manifest, "not base64", &key).is_err());
290 }
291
292 /// Made by scripts/runner-release.mjs, whose signing the CLI's releases use.
293 #[test]
294 fn the_release_scripts_signatures_check_out() {
295 let key = "4wuwkRbieiO9sWq1UBAcGDjAjin4cwJRG2F8LJVyr6U=";
296 let signature = "aXjDOfxYhm+iHacZwsxMadNxoHX07p62evYNqsXX4UZoDQ7pwWtFnF4jKfiqvAk+AmGVkcE+/uioMR2v+FixCw==";
297 assert!(verify(br#"{"version":"0.2.0","files":{}}"#, signature, key).is_ok());
298 assert!(verify(br#"{"version":"0.2.1","files":{}}"#, signature, key).is_err());
299 }
300
301 #[test]
302 fn platforms_are_named_as_releases_name_them() {
303 let name = platform();
304 assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64"].contains(&name.as_str()), "{name}");
305 // The manifest the site publishes reads.
306 let manifest: Manifest =
307 serde_json::from_str(r#"{"version":"0.3.3","published_at":"2026-10-14T13:22:39.222Z","files":{"windows-x64":{"name":"g1t-windows-x64.exe","sha256":"61"}}}"#)
308 .unwrap();
309 assert_eq!(manifest.files["windows-x64"].name, "g1t-windows-x64.exe");
310 }
311}