Improve update release channels (#2145)

* Improve update release channels

* Address update channel review feedback

* Prevent beta update downgrades
This commit is contained in:
庄表伟
2026-05-26 23:30:58 +08:00
committed by GitHub
parent 795de325d7
commit bba5617942
5 changed files with 279 additions and 17 deletions
Generated
+1
View File
@@ -851,6 +851,7 @@ dependencies = [
"codewhale-state",
"dirs",
"reqwest",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
+1
View File
@@ -41,6 +41,7 @@ reqwest = { version = "0.13.1", default-features = false, features = ["json", "r
rusqlite = { version = "0.32.1", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
semver = "1.0.28"
thiserror = "2.0"
tokio = { version = "1.49.0", features = ["full"] }
toml = "0.9.7"
+1
View File
@@ -37,6 +37,7 @@ dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest = { workspace = true, features = ["blocking"] }
semver.workspace = true
tokio.workspace = true
sha2.workspace = true
tempfile = "3.16"
+24 -2
View File
@@ -232,7 +232,14 @@ The command prints the completion script to stdout; redirect it to a path your s
/// Print a usage rollup from the audit log and session store.
Metrics(MetricsArgs),
/// Check for and apply updates to the `codewhale` binary.
Update,
Update(UpdateArgs),
}
#[derive(Debug, Args)]
struct UpdateArgs {
/// Update to the latest beta release instead of the latest stable release.
#[arg(long)]
beta: bool,
}
#[derive(Debug, Args)]
@@ -557,7 +564,7 @@ fn run() -> Result<()> {
Ok(())
}
Some(Commands::Metrics(args)) => run_metrics_command(args),
Some(Commands::Update) => update::run_update(),
Some(Commands::Update(args)) => update::run_update(args.beta),
None => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
let forwarded = root_tui_passthrough(&cli)?;
@@ -1790,6 +1797,21 @@ mod tests {
));
}
#[test]
fn parses_update_beta_flag() {
let cli = parse_ok(&["codewhale", "update"]);
assert!(matches!(
cli.command,
Some(Commands::Update(UpdateArgs { beta: false }))
));
let cli = parse_ok(&["codewhale", "update", "--beta"]);
assert!(matches!(
cli.command,
Some(Commands::Update(UpdateArgs { beta: true }))
));
}
#[test]
fn parses_model_command_matrix() {
let cli = parse_ok(&["deepseek", "model", "list"]);
+252 -15
View File
@@ -13,6 +13,7 @@ use std::io::Write;
const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
const RELEASES_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
const RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
@@ -21,18 +22,34 @@ const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
const UPDATE_USER_AGENT: &str = "codewhale-updater";
/// Run the self-update workflow.
pub fn run_update() -> Result<()> {
pub fn run_update(beta: bool) -> Result<()> {
let current_exe =
std::env::current_exe().context("failed to determine current executable path")?;
let targets = update_targets_for_exe(&current_exe);
let channel = ReleaseChannel::from_beta_flag(beta);
let current_version = env!("CARGO_PKG_VERSION");
println!("Checking for updates...");
println!("Checking for {} updates...", channel.label());
println!("Current binary: {}", current_exe.display());
println!("Current version: v{current_version}");
// Step 1: Fetch latest release metadata
let release = fetch_latest_release().with_context(update_network_fallback_hint)?;
let fetched = fetch_latest_release(channel).with_context(update_network_fallback_hint)?;
let release = &fetched.release;
let latest_tag = &release.tag_name;
println!("Latest release: {latest_tag}");
println!("Latest {} release: {latest_tag}", channel.label());
if let ReleaseSource::Mirror { base_url } = &fetched.source {
if channel == ReleaseChannel::Beta {
println!(
"Using release mirror {}; --beta does not select GitHub beta releases in mirror mode.",
base_url
);
}
} else if !update_is_needed(channel, current_version, latest_tag)? {
println!("Already up to date; no download needed.");
return Ok(());
}
// Step 2: Download the aggregated SHA256 checksum manifest if available
let checksum_manifest = match select_checksum_manifest_asset(&release) {
@@ -122,6 +139,37 @@ pub fn run_update() -> Result<()> {
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReleaseChannel {
Stable,
Beta,
}
impl ReleaseChannel {
fn from_beta_flag(beta: bool) -> Self {
if beta { Self::Beta } else { Self::Stable }
}
fn label(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FetchedRelease {
release: Release,
source: ReleaseSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ReleaseSource {
GitHub,
Mirror { base_url: String },
}
pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str {
match arch {
"aarch64" => "arm64",
@@ -275,14 +323,16 @@ fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result<String>
}
/// GitHub release metadata.
#[derive(serde::Deserialize, Debug)]
#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
struct Release {
tag_name: String,
#[serde(default)]
prerelease: bool,
assets: Vec<Asset>,
}
/// A single release asset.
#[derive(serde::Deserialize, Debug)]
#[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
struct Asset {
name: String,
browser_download_url: String,
@@ -296,17 +346,27 @@ fn update_http_client() -> Result<reqwest::blocking::Client> {
}
/// Fetch the latest release metadata from GitHub.
fn fetch_latest_release() -> Result<Release> {
fn fetch_latest_release(channel: ReleaseChannel) -> Result<FetchedRelease> {
if let Some(base_url) = release_base_url_from_env() {
let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
return Ok(release_from_mirror_base_url(
&base_url,
&version,
std::env::consts::OS,
std::env::consts::ARCH,
));
return Ok(FetchedRelease {
release: release_from_mirror_base_url(
&base_url,
&version,
std::env::consts::OS,
std::env::consts::ARCH,
),
source: ReleaseSource::Mirror { base_url },
});
}
fetch_latest_release_from_url(LATEST_RELEASE_URL)
let release = match channel {
ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL),
ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL),
}?;
Ok(FetchedRelease {
release,
source: ReleaseSource::GitHub,
})
}
fn release_base_url_from_env() -> Option<String> {
@@ -345,7 +405,11 @@ fn release_from_mirror_base_url(
});
}
Release { tag_name, assets }
Release {
tag_name,
prerelease: false,
assets,
}
}
fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
@@ -388,6 +452,81 @@ fn fetch_latest_release_from_url(url: &str) -> Result<Release> {
Ok(release)
}
fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
let client = update_http_client()?;
let response = client
.get(url)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.with_context(|| format!("failed to fetch release list from {url}"))?;
let status = response.status();
let body = response
.text()
.with_context(|| format!("failed to read release list response from {url}"))?;
if !status.is_success() {
bail!("GitHub release list request failed with HTTP {status}: {body}");
}
// GitHub caps this endpoint at 100 releases per page. CodeWhale uses the
// first page as the latest-beta search window, matching GitHub's ordering.
let releases: Vec<Release> = serde_json::from_str(&body).with_context(|| {
format!("failed to parse release list JSON from GitHub API. Response: {body}")
})?;
releases
.into_iter()
.find(is_beta_release)
.context("no beta release found in GitHub releases")
}
fn is_beta_release(release: &Release) -> bool {
release.tag_name.to_ascii_lowercase().contains("beta")
}
fn update_is_needed(
channel: ReleaseChannel,
current_version: &str,
latest_tag: &str,
) -> Result<bool> {
let current = parse_release_version(current_version)
.with_context(|| format!("failed to parse current version {current_version:?}"))?;
let latest = parse_release_version(latest_tag)
.with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
match channel {
ReleaseChannel::Stable => Ok(current < latest),
ReleaseChannel::Beta => {
if current == latest {
return Ok(false);
}
let latest_is_beta = version_is_beta(&latest);
let current_is_stable = current.pre.is_empty();
let same_release_line = current.major == latest.major
&& current.minor == latest.minor
&& current.patch == latest.patch;
if current > latest && !(current_is_stable && same_release_line) {
return Ok(false);
}
Ok(latest_is_beta)
}
}
}
fn parse_release_version(value: &str) -> Result<semver::Version> {
let version = value
.trim()
.trim_start_matches('v')
.split_whitespace()
.next()
.unwrap_or("");
semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
}
fn version_is_beta(version: &semver::Version) -> bool {
version.pre.as_str().to_ascii_lowercase().contains("beta")
}
/// Download a URL to bytes.
fn download_url(url: &str) -> Result<Vec<u8>> {
let client = update_http_client()?;
@@ -837,6 +976,62 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
);
}
#[test]
fn stable_update_is_needed_only_when_latest_is_newer() {
assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
assert!(
!update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
);
}
#[test]
fn beta_update_allows_switching_from_same_stable_to_beta() {
assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
}
#[test]
fn parse_release_version_accepts_tags_and_build_suffixes() {
assert_eq!(
parse_release_version("v0.9.0-beta.1").unwrap(),
semver::Version::parse("0.9.0-beta.1").unwrap()
);
assert_eq!(
parse_release_version("0.8.45 (abcdef123456)").unwrap(),
semver::Version::parse("0.8.45").unwrap()
);
}
#[test]
fn beta_release_detection_requires_beta_tag() {
let rc_prerelease = Release {
tag_name: "v0.9.0-rc.1".to_string(),
prerelease: true,
assets: vec![],
};
let beta_tag = Release {
tag_name: "v0.9.0-beta.1".to_string(),
prerelease: false,
assets: vec![],
};
let stable = Release {
tag_name: "v0.9.0".to_string(),
prerelease: false,
assets: vec![],
};
assert!(!is_beta_release(&rc_prerelease));
assert!(is_beta_release(&beta_tag));
assert!(!is_beta_release(&stable));
}
#[test]
fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() {
let hint = update_network_fallback_hint();
@@ -919,6 +1114,48 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
handle.join().expect("test server thread");
}
#[test]
fn fetch_latest_beta_release_from_url_selects_first_beta_release() {
let body = br#"[
{ "tag_name": "v0.9.0", "prerelease": false, "assets": [] },
{ "tag_name": "v0.9.0-rc.1", "prerelease": true, "assets": [] },
{ "tag_name": "v0.9.0-beta.2", "prerelease": true, "assets": [
{ "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" }
] },
{ "tag_name": "v0.9.0-beta.1", "prerelease": true, "assets": [] }
]"#;
let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body);
let release =
fetch_latest_beta_release_from_url(&url).expect("beta release JSON should parse");
assert_eq!(release.tag_name, "v0.9.0-beta.2");
assert!(release.prerelease);
let request = request_rx.recv().expect("captured request");
let request_lower = request.to_ascii_lowercase();
assert!(request.starts_with("GET /release "), "got {request:?}");
assert!(
request_lower.contains("accept: application/vnd.github+json"),
"got {request:?}"
);
handle.join().expect("test server thread");
}
#[test]
fn fetch_latest_beta_release_from_url_reports_missing_beta() {
let body = br#"[
{ "tag_name": "v0.9.0", "prerelease": false, "assets": [] }
]"#;
let (url, _request_rx, handle) = serve_http_once("200 OK", "application/json", body);
let err = fetch_latest_beta_release_from_url(&url).expect_err("missing beta should fail");
assert!(
err.to_string().contains("no beta release found"),
"unexpected error: {err:#}"
);
handle.join().expect("test server thread");
}
#[test]
fn download_url_reads_binary_body_with_updater_user_agent() {
let (url, request_rx, handle) =