mirror of
https://github.com/nushell/nushell.git
synced 2026-10-28 17:05:03 +08:00
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:
1 parent
77726d8d22
commit
324696443a
5 files changed
+208
-137
No files matched your search
Generated
-58
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
mod client;
|
||||
pub(super) mod client;
|
||||
mod delete;
|
||||
mod get;
|
||||
mod head;
|
||||
|
||||
@@ -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(¤t_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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user