Skip to content

Commit 7d197f1

Browse files
feat(cli): check for newer GitHub release on every user-facing command (#637)
* feat(cli): check for newer GitHub release on every user-facing command Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/bfe61ddc-7976-4f32-a4db-25e14d765fb8 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fixup: address code review comments in update_check Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/bfe61ddc-7976-4f32-a4db-25e14d765fb8 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix(update_check): spawn concurrently, sanitize output, skip in CI Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/68be7740-0be2-4ed3-9783-33cb9c8a85b5 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent c9185c6 commit 7d197f1

2 files changed

Lines changed: 147 additions & 0 deletions

File tree

src/main.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod sanitize;
2424
mod secrets;
2525
mod status;
2626
mod tools;
27+
mod update_check;
2728
pub mod validate;
2829

2930
use anyhow::{Context, Result};
@@ -773,6 +774,20 @@ async fn main() -> Result<()> {
773774
return Ok(());
774775
};
775776

777+
// Check for a newer release on GitHub and nudge the user to update.
778+
// Skipped for pipeline-internal commands (execute, mcp, mcp-http) that
779+
// run inside network-isolated sandboxes and are not invoked by humans.
780+
// Also skipped in CI environments to avoid unnecessary outbound calls.
781+
let is_pipeline_internal = matches!(
782+
command,
783+
Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpHttp { .. }
784+
);
785+
let update_handle = if !is_pipeline_internal && std::env::var_os("CI").is_none() {
786+
Some(tokio::spawn(update_check::check_for_update()))
787+
} else {
788+
None
789+
};
790+
776791
match command {
777792
Commands::Compile {
778793
path,
@@ -1082,6 +1097,13 @@ async fn main() -> Result<()> {
10821097
}
10831098
}
10841099
}
1100+
1101+
// Wait for the background update check to finish so the advisory (if any)
1102+
// is printed before the process exits.
1103+
if let Some(handle) = update_handle {
1104+
let _ = handle.await;
1105+
}
1106+
10851107
Ok(())
10861108
}
10871109

src/update_check.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//! Version update check.
2+
//!
3+
//! On every user-facing command invocation, queries the GitHub Releases API
4+
//! for the latest `githubnext/ado-aw` release and prints an advisory message
5+
//! to stderr when a newer version is available. All network errors are
6+
//! silently swallowed (logged at `debug` level) so a transient network hiccup
7+
//! never interrupts the user's workflow.
8+
9+
use serde::Deserialize;
10+
11+
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
12+
const RELEASES_API: &str =
13+
"https://api.github.com/repos/githubnext/ado-aw/releases/latest";
14+
15+
#[derive(Deserialize)]
16+
struct LatestRelease {
17+
tag_name: String,
18+
}
19+
20+
/// Check GitHub Releases for a newer version and, if one is found, print an
21+
/// advisory to stderr. Always returns `()` — errors are absorbed.
22+
pub async fn check_for_update() {
23+
match fetch_latest_tag().await {
24+
Ok(tag) => {
25+
let latest = tag.trim_start_matches('v');
26+
// Only print if the version parses to a valid semver triple so we
27+
// never forward raw API content (e.g. ANSI escape sequences) to
28+
// the terminal. Use the reconstructed string, not `latest`.
29+
if let Some((maj, min, pat)) = parse_version(latest) {
30+
if (maj, min, pat) > parse_version(CURRENT_VERSION).unwrap_or((0, 0, 0)) {
31+
eprintln!(
32+
"A newer version of ado-aw is available: v{maj}.{min}.{pat} (you have v{CURRENT_VERSION}).\n\
33+
Update at: https://github.com/githubnext/ado-aw/releases/latest"
34+
);
35+
}
36+
}
37+
}
38+
Err(e) => {
39+
log::debug!("Update check failed (non-fatal): {e}");
40+
}
41+
}
42+
}
43+
44+
async fn fetch_latest_tag() -> anyhow::Result<String> {
45+
let client = reqwest::Client::builder()
46+
.user_agent(format!("ado-aw/{CURRENT_VERSION}"))
47+
.timeout(std::time::Duration::from_secs(5))
48+
.build()?;
49+
50+
let release: LatestRelease = client
51+
.get(RELEASES_API)
52+
.send()
53+
.await?
54+
.error_for_status()?
55+
.json()
56+
.await?;
57+
58+
Ok(release.tag_name)
59+
}
60+
61+
/// Parse a bare semver string like `"0.31.0"` into `(major, minor, patch)`.
62+
/// Pre-release suffixes on the patch component (e.g. `"3-beta"`) are accepted;
63+
/// only the leading numeric part of patch is used. Returns `None` if the
64+
/// string is not a valid semver triple.
65+
fn parse_version(s: &str) -> Option<(u64, u64, u64)> {
66+
let mut it = s.split('.');
67+
let major = it.next()?.parse().ok()?;
68+
let minor = it.next()?.parse().ok()?;
69+
let patch: u64 = it
70+
.next()?
71+
.split(|c: char| !c.is_ascii_digit())
72+
.next()
73+
.and_then(|n| n.parse().ok())?;
74+
Some((major, minor, patch))
75+
}
76+
77+
/// Returns `true` when `latest` is strictly greater than `current`.
78+
/// Both strings are expected to be bare semver triples, e.g. `"0.31.0"`.
79+
/// Extra version components (pre-release suffixes, build metadata) are ignored.
80+
#[cfg(test)]
81+
fn is_newer(latest: &str, current: &str) -> bool {
82+
match (parse_version(latest), parse_version(current)) {
83+
(Some(l), Some(c)) => l > c,
84+
_ => false,
85+
}
86+
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use super::is_newer;
91+
92+
#[test]
93+
fn newer_patch() {
94+
assert!(is_newer("0.30.3", "0.30.2"));
95+
}
96+
97+
#[test]
98+
fn newer_minor() {
99+
assert!(is_newer("0.31.0", "0.30.2"));
100+
}
101+
102+
#[test]
103+
fn newer_major() {
104+
assert!(is_newer("1.0.0", "0.30.2"));
105+
}
106+
107+
#[test]
108+
fn same_version() {
109+
assert!(!is_newer("0.30.2", "0.30.2"));
110+
}
111+
112+
#[test]
113+
fn older_version() {
114+
assert!(!is_newer("0.29.0", "0.30.2"));
115+
}
116+
117+
#[test]
118+
fn v_prefix_already_stripped_by_caller() {
119+
// check_for_update() strips the 'v' before calling is_newer; verify
120+
// that stripping works end-to-end by simulating it here.
121+
let tag = "v0.31.0";
122+
let stripped = tag.trim_start_matches('v');
123+
assert!(is_newer(stripped, "0.30.2"));
124+
}
125+
}

0 commit comments

Comments
 (0)