Improve update release channels (#2145)
* Improve update release channels * Address update channel review feedback * Prevent beta update downgrades
This commit is contained in:
Generated
+1
@@ -851,6 +851,7 @@ dependencies = [
|
||||
"codewhale-state",
|
||||
"dirs",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
|
||||
+248
-11
@@ -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(¤t_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(
|
||||
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) =
|
||||
|
||||
Reference in New Issue
Block a user