fix(version check): propagate error and use github token (#19298)

<!--
Thanks for contributing to Nushell!

Before submitting, please read the contributing guide:
https://github.com/nushell/nushell/blob/main/CONTRIBUTING.md

This template helps reviewers understand your changes and allows us to
generate high-quality release notes.
-->

## Description
<!--
Explain what this PR does and why.

This section is intentionally flexible:
- Describe the problem
- Explain your approach
- Include technical details if relevant

Good examples:
- "In this PR, I fixed..."
- "In this PR, I added support for..."
- "This change improves X by..."

Write as much or as little as needed for reviewers to understand your
changes.
-->

## User-facing changes (Release notes)
<!--
This section is used (mostly as-is) for https://www.nushell.sh/blog/

Describe how Nushell behavior changes from a user's perspective.
Do NOT describe internal Rust changes here.

Write in a release note style, for example:
- "Added support for..."
- "Fixed an issue where..."
- "Nushell now supports..."
- "Improved performance of..."

If your changes do NOT affect users (internal refactors, cleanup, etc.),
just write:
- "n/a"
- "nan"
- or similar

This tells us the change should not appear in the changelog.

Tips:
- Focus on observable behavior
- Include examples if helpful
- Keep it concise

You can:
- Write a short paragraph (will appear as a bullet point), OR
- Use headings (###) if your change needs more structure

Avoid writing things like:
- "In this PR, I refactored..."
- "This updates internal code..."

You may leave this blank until the PR is ready.
-->
Previously, the `version check` command would say you are on the latest
release if the lookup failed, now, lookup errors will result in an error
on the command too. Also, if a github token is available on the
environment (`GITHUB_TOKEN` or `GH_TOKEN`) it will be used for the
request to bypass the unauthenticated 60-requests-per-hour github api
rate limit.
This commit is contained in:
Tyarel8 authored and GitHub committed 2026-10-20 06:40:51 -05:00
1 parent 77726d8d22
commit 324696443a
5 files changed
+208 -137

No files matched your search

Generated
-58
View File
@@ -2353,17 +2353,6 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "etcetera"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.59.0",
]
[[package]]
name = "ethnum"
version = "1.5.3"
@@ -3322,23 +3311,6 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
@@ -5031,7 +5003,6 @@ dependencies = [
"umask",
"unicode-segmentation",
"unicode-width 0.2.2",
"update-informer",
"ureq",
"url",
"uu_cp",
@@ -7969,7 +7940,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -7978,11 +7948,9 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -7994,7 +7962,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -8005,7 +7972,6 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -9476,16 +9442,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -9932,20 +9888,6 @@ version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "update-informer"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b27dcf766dc6ad64c2085201626e1a7955dc1983532bfc8406d552903ace2a"
dependencies = [
"etcetera",
"reqwest",
"semver",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "ureq"
version = "3.4.0"
-1
View File
@@ -277,7 +277,6 @@ toml_edit = "0.25.13"
kdl = { version = "6.7.1", features = ["v1"] }
trash = "=5.2.9"
typetag = "0.2.21"
update-informer = { version = "1.3.0", default-features = false, features = ["github", "ureq"] }
umask = "2.1"
unicode-segmentation = "1.13"
unicode-width = "0.2"
+1 -4
View File
@@ -127,7 +127,6 @@ toml = { workspace = true, features = ["preserve_order"] }
toml_edit = { workspace = true }
kdl = { workspace = true }
unicode-segmentation = { workspace = true }
update-informer = { workspace = true, optional = true }
ureq = { workspace = true, default-features = false, features = [
"charset",
"gzip",
@@ -240,18 +239,16 @@ network = [
"dns-lookup",
"multipart-rs",
"nu-test-support/network",
"update-informer",
"ureq",
"uuid",
]
native-tls = ["dep:native-tls", "update-informer/native-tls", "ureq/native-tls"]
native-tls = ["dep:native-tls", "ureq/native-tls"]
rustls-tls = [
"dep:rustls-native-certs",
"dep:rustls",
"dep:webpki-roots",
"nu-test-support/rustls-tls",
"update-informer/rustls-tls",
"ureq/rustls",
]
+1 -1
View File
@@ -1,4 +1,4 @@
mod client;
pub(super) mod client;
mod delete;
mod get;
mod head;
+206 -73
View File
@@ -1,10 +1,11 @@
use crate::network::http::client::{RedirectMode, http_client, send_request_no_body};
use nu_engine::command_prelude::*;
use nu_protocol::Signals;
use nu_protocol::shell_error::generic::GenericError;
use semver::Version;
use serde::Deserialize;
use update_informer::{
Check, Package, Registry, Result as UpdateResult,
http_client::{GenericHttpClient, HttpClient},
registry,
};
use serde::de::DeserializeOwned;
use std::borrow::Cow;
#[derive(Clone)]
pub struct VersionCheck;
@@ -19,7 +20,12 @@ impl Command for VersionCheck {
}
fn extra_description(&self) -> &str {
"If you're running nushell nightly, `version check` will check to see if you are running the latest nightly version. If you are running the nushell release, `version check` will check to see if you're running the latest release version."
"If you're running nushell nightly, `version check` will check to see if you are running
the latest nightly version. If you are running the nushell release, `version check` will check
to see if you're running the latest release version.
If `GITHUB_TOKEN` (or `GH_TOKEN`) is set in the environment, it will be used to authenticate the
check against the GitHub API. When both are set, `GH_TOKEN` takes precedence, as with the `gh` CLI."
}
fn signature(&self) -> Signature {
@@ -38,95 +44,222 @@ impl Command for VersionCheck {
fn run(
&self,
_engine_state: &EngineState,
_stack: &mut Stack,
_call: &Call,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let version_check = check_for_latest_nushell_version();
let agent = http_client(false, RedirectMode::Follow, None, engine_state, stack)?;
let token = github_token(engine_state, stack);
let signals = engine_state.signals();
let version_check = check_for_latest_nushell_version(&agent, signals, token, call.head)?;
Ok(version_check.into_pipeline_data())
}
}
pub struct NuShellNightly;
#[derive(Deserialize, Debug)]
struct GitHubRelease {
tag_name: String,
}
impl Registry for NuShellNightly {
const NAME: &'static str = "nushell/nightly";
/// Gets a JSON document from GitHub's API without treating non-2xx responses as success.
///
/// Errors from the HTTP stack are passed along as they are; a non-2xx status or an unparsable
/// body is reported with a hint based on what happened.
fn github_get_json<T: DeserializeOwned>(
agent: &ureq::Agent,
url: &str,
token: Option<&str>,
signals: &Signals,
span: Span,
) -> Result<T, ShellError> {
let mut response = send_github_request(agent, url, token, signals, span)?;
fn get_latest_version<T: HttpClient>(
http_client: GenericHttpClient<T>,
pkg: &Package,
) -> UpdateResult<Option<String>> {
#[derive(Deserialize, Debug)]
struct Response {
tag_name: String,
// GitHub rejects an invalid token with a 401 status, so an authorized request that fails with
// one is retried unauthenticated; without a token the check works with the unauthenticated
// API rate limit.
if response.status().as_u16() == 401 && token.is_some() {
response = send_github_request(agent, url, None, signals, span)?;
}
let status = response.status();
if !status.is_success() {
return Err(github_check_error(
format!("http status: {status}"),
github_status_help(status.as_u16()),
span,
));
}
response.body_mut().read_json::<T>().map_err(|err| {
github_check_error(
err.to_string(),
"The response from GitHub is not the expected release data",
span,
)
})
}
/// Sends a GET request to the GitHub API.
fn send_github_request(
agent: &ureq::Agent,
url: &str,
token: Option<&str>,
signals: &Signals,
span: Span,
) -> Result<http::Response<ureq::Body>, ShellError> {
let (response, _) =
send_request_no_body(build_github_request(agent, url, token), span, span, signals);
response
}
fn build_github_request(
agent: &ureq::Agent,
url: &str,
token: Option<&str>,
) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
let mut request = agent
.get(url)
.header("Accept", "application/vnd.github.v3+json");
if let Some(token) = token {
request = request.header("Authorization", format!("Bearer {token}"));
}
request
}
/// Help for errors from the status the GitHub API reported.
fn github_status_help(status: u16) -> &'static str {
match status {
401 => {
"GitHub rejected the request as unauthorized; if GITHUB_TOKEN (or GH_TOKEN) is set, it is likely invalid or expired"
}
let url = format!("https://api.github.com/repos/{pkg}/releases");
let versions = http_client
.add_header("Accept", "application/vnd.github.v3+json")
.add_header("User-Agent", "update-informer")
.get::<Vec<Response>>(&url)?;
if let Some(v) = versions.first() {
// The nightly repo tags look like "0.102.0-nightly.4+23dc1b6"
// We want to return the "0.102.0-nightly.4" part because hustcer
// is changing the cargo.toml package.version to be that syntax
let up_through_plus = match v.tag_name.split('+').next() {
Some(v) => v,
None => &v.tag_name,
};
return Ok(Some(up_through_plus.to_string()));
403 | 429 => {
"GitHub is rate limiting you; a valid GITHUB_TOKEN (or GH_TOKEN) raises the request limit"
}
Ok(None)
_ => "GitHub may be having API problems",
}
}
pub fn check_for_latest_nushell_version() -> Value {
/// Creates the error `version check` reports when the check with GitHub failed.
fn github_check_error(
msg: impl Into<Cow<'static, str>>,
help: &'static str,
span: Span,
) -> ShellError {
ShellError::Generic(
GenericError::new("failed to check for the latest version", msg, span).with_help(help),
)
}
/// Looks up `GITHUB_TOKEN` (or `GH_TOKEN`), skipping variables that do not hold a non-empty token.
///
/// `GH_TOKEN` takes precedence, as with the `gh` CLI.
fn github_token(engine_state: &EngineState, stack: &Stack) -> Option<String> {
["GH_TOKEN", "GITHUB_TOKEN"].iter().find_map(|name| {
stack
.get_env_var(engine_state, name)
.and_then(|value| value.coerce_str().ok())
.map(|token| token.trim().to_owned())
.filter(|token| !token.is_empty())
})
}
fn check_for_latest_nushell_version(
agent: &ureq::Agent,
signals: &Signals,
token: Option<String>,
span: Span,
) -> Result<Value, ShellError> {
let current_version = env!("CARGO_PKG_VERSION").to_string();
let mut rec = Record::new();
if current_version.contains("nightly") {
rec.push("channel", Value::test_string("nightly"));
let latest_tag = if current_version.contains("nightly") {
rec.push("channel", Value::string("nightly", span));
let nightly_pkg_name = "nushell/nightly";
// The .interval() determines how long the cached check lives. Setting it to std::time::Duration::ZERO
// means that there is essentially no cache and it will check for a new version each time you run nushell.
// Since this is run on demand, there isn't really a need to cache the check.
let informer =
update_informer::new(NuShellNightly, nightly_pkg_name, current_version.clone())
.interval(std::time::Duration::ZERO);
let url = "https://api.github.com/repos/nushell/nightly/releases";
let releases: Vec<GitHubRelease> =
github_get_json(agent, url, token.as_deref(), signals, span)?;
if let Ok(Some(new_version)) = informer.check_version() {
rec.push("current", Value::test_bool(false));
rec.push("latest", Value::test_string(format!("{new_version}")));
Value::test_record(rec)
} else {
rec.push("current", Value::test_bool(true));
rec.push("latest", Value::test_string(current_version.clone()));
Value::test_record(rec)
}
releases.first().map(|release| {
// Nightly repo tags look like "0.102.0-nightly.4+23dc1b6", but the running
// nightly build reports its version as "0.102.0-nightly.4", so the build
// metadata of the tag is stripped before the comparison.
release
.tag_name
.split('+')
.next()
.unwrap_or(&release.tag_name)
.to_owned()
})
} else {
rec.push("channel", Value::test_string("release"));
rec.push("channel", Value::string("release", span));
let normal_pkg_name = "nushell/nushell";
// By default, this update request is cached for 24 hours so it won't check for a new version
// each time you run nushell. Since this is run on demand, there isn't really a need to cache the check which
// is why we set the interval to std::time::Duration::ZERO.
let informer =
update_informer::new(registry::GitHub, normal_pkg_name, current_version.clone())
.interval(std::time::Duration::ZERO);
let url = "https://api.github.com/repos/nushell/nushell/releases/latest";
let release: GitHubRelease = github_get_json(agent, url, token.as_deref(), signals, span)?;
Some(release.tag_name)
};
if let Ok(Some(new_version)) = informer.check_version() {
rec.push("current", Value::test_bool(false));
rec.push("latest", Value::test_string(format!("{new_version}")));
Value::test_record(rec)
} else {
rec.push("current", Value::test_bool(true));
rec.push("latest", Value::test_string(current_version.clone()));
Value::test_record(rec)
let latest = latest_tag
.map(|tag| {
Version::parse(tag.strip_prefix('v').unwrap_or(&tag)).map_err(|err| {
github_check_error(
err.to_string(),
"The release reported by GitHub does not use a valid version",
span,
)
})
})
.transpose()?;
let current = Version::parse(&current_version).map_err(|err| {
github_check_error(
err.to_string(),
"The running version of nushell is not a valid semver version",
span,
)
})?;
match latest {
Some(latest) if latest > current => {
rec.push("current", Value::bool(false, span));
rec.push("latest", Value::string(format!("v{latest}"), span));
}
_ => {
rec.push("current", Value::bool(true, span));
rec.push("latest", Value::string(current_version, span));
}
}
Ok(Value::record(rec, span))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_token_takes_gh_token_first_like_gh_cli() {
let engine_state = EngineState::new();
let mut stack = Stack::new();
stack.add_env_var("GITHUB_TOKEN".to_owned(), Value::test_string("first"));
stack.add_env_var("GH_TOKEN".to_owned(), Value::test_string("second"));
assert_eq!(
github_token(&engine_state, &stack).as_deref(),
Some("second")
);
}
#[test]
fn github_token_falls_through_empty_values_to_the_next_name() {
let engine_state = EngineState::new();
let mut stack = Stack::new();
stack.add_env_var("GH_TOKEN".to_owned(), Value::test_string(" "));
stack.add_env_var("GITHUB_TOKEN".to_owned(), Value::test_string("token"));
assert_eq!(
github_token(&engine_state, &stack).as_deref(),
Some("token")
);
}
}