From 6eaa3378b777e6a7af3a63570ea72d8afab186c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 08:24:44 +0000 Subject: [PATCH 1/3] 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> --- src/main.rs | 12 +++++ src/update_check.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/update_check.rs diff --git a/src/main.rs b/src/main.rs index faacb3db..01775f6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ pub mod sanitize; mod secrets; mod status; mod tools; +mod update_check; pub mod validate; use anyhow::{Context, Result}; @@ -773,6 +774,17 @@ async fn main() -> Result<()> { return Ok(()); }; + // Check for a newer release on GitHub and nudge the user to update. + // Skipped for pipeline-internal commands (execute, mcp, mcp-http) that + // run inside network-isolated sandboxes and are not invoked by humans. + let is_pipeline_internal = matches!( + command, + Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpHttp { .. } + ); + if !is_pipeline_internal { + update_check::check_for_update().await; + } + match command { Commands::Compile { path, diff --git a/src/update_check.rs b/src/update_check.rs new file mode 100644 index 00000000..6c8067ab --- /dev/null +++ b/src/update_check.rs @@ -0,0 +1,112 @@ +//! Version update check. +//! +//! On every user-facing command invocation, queries the GitHub Releases API +//! for the latest `githubnext/ado-aw` release and prints an advisory message +//! to stderr when a newer version is available. All network errors are +//! silently swallowed (logged at `debug` level) so a transient network hiccup +//! never interrupts the user's workflow. + +use serde::Deserialize; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const RELEASES_API: &str = + "https://api.github.com/repos/githubnext/ado-aw/releases/latest"; + +#[derive(Deserialize)] +struct LatestRelease { + tag_name: String, +} + +/// Check GitHub Releases for a newer version and, if one is found, print an +/// advisory to stderr. Always returns `()` — errors are absorbed. +pub async fn check_for_update() { + match fetch_latest_tag().await { + Ok(tag) => { + let latest = tag.trim_start_matches('v'); + if is_newer(latest, CURRENT_VERSION) { + eprintln!( + "A newer version of ado-aw is available: v{latest} (you have v{CURRENT_VERSION}).\n\ + Update at: https://github.com/githubnext/ado-aw/releases/latest" + ); + } + } + Err(e) => { + log::debug!("Update check failed (non-fatal): {e}"); + } + } +} + +async fn fetch_latest_tag() -> anyhow::Result { + let client = reqwest::Client::builder() + .user_agent(format!("ado-aw/{CURRENT_VERSION}")) + .timeout(std::time::Duration::from_secs(5)) + .build()?; + + let release: LatestRelease = client + .get(RELEASES_API) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(release.tag_name) +} + +/// Returns `true` when `latest` is strictly greater than `current`. +/// Both strings are expected to be bare semver triples, e.g. `"0.31.0"`. +fn is_newer(latest: &str, current: &str) -> bool { + fn parse(s: &str) -> Option<(u64, u64, u64)> { + let mut it = s.splitn(4, '.'); // splitn(4) so "1.2.3.4" isn't equal to "1.2.3" + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + // Allow patch to have a pre-release suffix; only the numeric part matters. + let patch_str = it.next()?; + let patch: u64 = patch_str + .split(|c: char| !c.is_ascii_digit()) + .next() + .and_then(|n| n.parse().ok())?; + Some((major, minor, patch)) + } + + match (parse(latest), parse(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::is_newer; + + #[test] + fn newer_patch() { + assert!(is_newer("0.30.3", "0.30.2")); + } + + #[test] + fn newer_minor() { + assert!(is_newer("0.31.0", "0.30.2")); + } + + #[test] + fn newer_major() { + assert!(is_newer("1.0.0", "0.30.2")); + } + + #[test] + fn same_version() { + assert!(!is_newer("0.30.2", "0.30.2")); + } + + #[test] + fn older_version() { + assert!(!is_newer("0.29.0", "0.30.2")); + } + + #[test] + fn v_prefix_stripped() { + // The caller strips the 'v' before passing here, but be defensive. + assert!(is_newer("0.31.0", "0.30.2")); + } +} From d6fe94f03b574a6c26a524491d5959a2be0d002b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 08:29:34 +0000 Subject: [PATCH 2/3] 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> --- src/update_check.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/update_check.rs b/src/update_check.rs index 6c8067ab..b8246a38 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -55,14 +55,16 @@ async fn fetch_latest_tag() -> anyhow::Result { /// Returns `true` when `latest` is strictly greater than `current`. /// Both strings are expected to be bare semver triples, e.g. `"0.31.0"`. +/// Extra version components (pre-release suffixes, build metadata) are ignored. fn is_newer(latest: &str, current: &str) -> bool { fn parse(s: &str) -> Option<(u64, u64, u64)> { - let mut it = s.splitn(4, '.'); // splitn(4) so "1.2.3.4" isn't equal to "1.2.3" + let mut it = s.split('.'); let major = it.next()?.parse().ok()?; let minor = it.next()?.parse().ok()?; - // Allow patch to have a pre-release suffix; only the numeric part matters. - let patch_str = it.next()?; - let patch: u64 = patch_str + // Allow patch to carry a pre-release suffix (e.g. "3-beta"); only the + // leading numeric part matters for the comparison. + let patch: u64 = it + .next()? .split(|c: char| !c.is_ascii_digit()) .next() .and_then(|n| n.parse().ok())?; @@ -105,8 +107,11 @@ mod tests { } #[test] - fn v_prefix_stripped() { - // The caller strips the 'v' before passing here, but be defensive. - assert!(is_newer("0.31.0", "0.30.2")); + fn v_prefix_already_stripped_by_caller() { + // check_for_update() strips the 'v' before calling is_newer; verify + // that stripping works end-to-end by simulating it here. + let tag = "v0.31.0"; + let stripped = tag.trim_start_matches('v'); + assert!(is_newer(stripped, "0.30.2")); } } From b6cbea1e5441a1f2c0555428110c25c9d6984a2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 10:18:06 +0000 Subject: [PATCH 3/3] 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> --- src/main.rs | 16 ++++++++++++--- src/update_check.rs | 48 ++++++++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/main.rs b/src/main.rs index 01775f6d..1defa470 100644 --- a/src/main.rs +++ b/src/main.rs @@ -777,13 +777,16 @@ async fn main() -> Result<()> { // Check for a newer release on GitHub and nudge the user to update. // Skipped for pipeline-internal commands (execute, mcp, mcp-http) that // run inside network-isolated sandboxes and are not invoked by humans. + // Also skipped in CI environments to avoid unnecessary outbound calls. let is_pipeline_internal = matches!( command, Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpHttp { .. } ); - if !is_pipeline_internal { - update_check::check_for_update().await; - } + let update_handle = if !is_pipeline_internal && std::env::var_os("CI").is_none() { + Some(tokio::spawn(update_check::check_for_update())) + } else { + None + }; match command { Commands::Compile { @@ -1094,6 +1097,13 @@ async fn main() -> Result<()> { } } } + + // Wait for the background update check to finish so the advisory (if any) + // is printed before the process exits. + if let Some(handle) = update_handle { + let _ = handle.await; + } + Ok(()) } diff --git a/src/update_check.rs b/src/update_check.rs index b8246a38..433b9922 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -23,11 +23,16 @@ pub async fn check_for_update() { match fetch_latest_tag().await { Ok(tag) => { let latest = tag.trim_start_matches('v'); - if is_newer(latest, CURRENT_VERSION) { - eprintln!( - "A newer version of ado-aw is available: v{latest} (you have v{CURRENT_VERSION}).\n\ - Update at: https://github.com/githubnext/ado-aw/releases/latest" - ); + // Only print if the version parses to a valid semver triple so we + // never forward raw API content (e.g. ANSI escape sequences) to + // the terminal. Use the reconstructed string, not `latest`. + if let Some((maj, min, pat)) = parse_version(latest) { + if (maj, min, pat) > parse_version(CURRENT_VERSION).unwrap_or((0, 0, 0)) { + eprintln!( + "A newer version of ado-aw is available: v{maj}.{min}.{pat} (you have v{CURRENT_VERSION}).\n\ + Update at: https://github.com/githubnext/ado-aw/releases/latest" + ); + } } } Err(e) => { @@ -53,25 +58,28 @@ async fn fetch_latest_tag() -> anyhow::Result { Ok(release.tag_name) } +/// Parse a bare semver string like `"0.31.0"` into `(major, minor, patch)`. +/// Pre-release suffixes on the patch component (e.g. `"3-beta"`) are accepted; +/// only the leading numeric part of patch is used. Returns `None` if the +/// string is not a valid semver triple. +fn parse_version(s: &str) -> Option<(u64, u64, u64)> { + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch: u64 = it + .next()? + .split(|c: char| !c.is_ascii_digit()) + .next() + .and_then(|n| n.parse().ok())?; + Some((major, minor, patch)) +} + /// Returns `true` when `latest` is strictly greater than `current`. /// Both strings are expected to be bare semver triples, e.g. `"0.31.0"`. /// Extra version components (pre-release suffixes, build metadata) are ignored. +#[cfg(test)] fn is_newer(latest: &str, current: &str) -> bool { - fn parse(s: &str) -> Option<(u64, u64, u64)> { - let mut it = s.split('.'); - let major = it.next()?.parse().ok()?; - let minor = it.next()?.parse().ok()?; - // Allow patch to carry a pre-release suffix (e.g. "3-beta"); only the - // leading numeric part matters for the comparison. - let patch: u64 = it - .next()? - .split(|c: char| !c.is_ascii_digit()) - .next() - .and_then(|n| n.parse().ok())?; - Some((major, minor, patch)) - } - - match (parse(latest), parse(current)) { + match (parse_version(latest), parse_version(current)) { (Some(l), Some(c)) => l > c, _ => false, }