feat: implement selfupdate command
Release / create-release (push) Failing after 31s
Release / build-linux-x86_64 (push) Has been skipped
Release / build-linux-aarch64 (push) Has been skipped
Release / build-macos (aarch64-apple-darwin) (push) Has been skipped
Release / build-macos (x86_64-apple-darwin) (push) Has been skipped
Release / build-windows (push) Has been skipped

- Fetch latest release from Gitea API using existing reqwest client
- Match release asset by BUILD_TARGET triple (supports .tar.gz and .zip)
- Compare versions; show confirmation prompt (skippable with -y/--yes)
- Download archive, extract binary, atomically replace self via self-replace
- Support private repositories via GITEA_TOKEN environment variable
- Expose BUILD_TARGET in build.rs for compile-time target triple detection
- Add .gitea/workflows/release.yml for multi-platform release builds on tag push

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-17 20:15:19 +09:00
co-authored by Copilot
parent 7947c3bae9
commit 3578a39d27
7 changed files with 832 additions and 1 deletions
+1
View File
@@ -18,3 +18,4 @@ pub mod shared;
pub mod upload;
pub mod version;
pub mod whoami;
pub mod selfupdate;
+218
View File
@@ -0,0 +1,218 @@
use anyhow::{anyhow, bail};
use reqwest::header::{AUTHORIZATION, USER_AGENT};
use serde::Deserialize;
use std::env;
use std::io::{self, Write};
use std::path::Path;
const GITEA_HOST: &str = "https://git.ni.riken.jp";
const REPO_OWNER: &str = "niu";
const REPO_NAME: &str = "mdrs-client-rust";
/// Current build target triple, captured at compile time via build.rs.
const BUILD_TARGET: &str = env!("BUILD_TARGET");
#[derive(Deserialize)]
struct GiteaRelease {
tag_name: String,
assets: Vec<GiteaAsset>,
}
#[derive(Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
/// Returns true if `latest` is strictly greater than `current` (semver-like comparison).
fn is_newer(current: &str, latest: &str) -> bool {
let parse = |s: &str| -> Vec<u64> {
s.trim_start_matches('v')
.split('.')
.map(|p| p.parse::<u64>().unwrap_or(0))
.collect()
};
let cur = parse(current);
let lat = parse(latest);
let len = cur.len().max(lat.len());
for i in 0..len {
let c = cur.get(i).copied().unwrap_or(0);
let l = lat.get(i).copied().unwrap_or(0);
if l > c {
return true;
}
if l < c {
return false;
}
}
false
}
/// Extract the binary named `bin_name` from a `.tar.gz` archive at `archive_path`
/// and write it to `dest_path`.
fn extract_from_tar_gz(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> {
use flate2::read::GzDecoder;
use tar::Archive;
let file = std::fs::File::open(archive_path)?;
let gz = GzDecoder::new(file);
let mut archive = Archive::new(gz);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
// Match by file name only (ignore directory prefix in archive).
if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
entry.unpack(dest_path)?;
return Ok(());
}
}
bail!("Binary '{}' not found in archive", bin_name)
}
/// Extract the binary named `bin_name` from a `.zip` archive at `archive_path`
/// and write it to `dest_path`.
fn extract_from_zip(archive_path: &Path, bin_name: &str, dest_path: &Path) -> anyhow::Result<()> {
use std::io::Read;
let file = std::fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let entry_name = entry.name().to_owned();
let file_name = Path::new(&entry_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if file_name == bin_name {
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
std::fs::write(dest_path, &buf)?;
return Ok(());
}
}
bail!("Binary '{}' not found in archive", bin_name)
}
pub async fn selfupdate(yes: bool) -> anyhow::Result<()> {
let current_version = env!("CARGO_PKG_VERSION");
println!("Checking for updates (current version: {current_version}, target: {BUILD_TARGET})...");
let api_url = format!(
"{GITEA_HOST}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases?limit=1"
);
let client = reqwest::Client::new();
let mut req = client
.get(&api_url)
.header(USER_AGENT, format!("mdrs/{current_version}"));
if let Ok(token) = env::var("GITEA_TOKEN") {
req = req.header(AUTHORIZATION, format!("Bearer {token}"));
}
let resp = req.send().await?;
if !resp.status().is_success() {
bail!("Failed to fetch release info: HTTP {}", resp.status());
}
let releases: Vec<GiteaRelease> = resp.json().await?;
let release = releases
.into_iter()
.next()
.ok_or_else(|| anyhow!("No releases found"))?;
let latest_version = release.tag_name.trim_start_matches('v');
if !is_newer(current_version, latest_version) {
println!("Already up-to-date ({current_version}).");
return Ok(());
}
println!("New version available: {latest_version}");
// Find the asset matching the current build target.
let asset = release
.assets
.iter()
.find(|a| a.name.contains(BUILD_TARGET))
.ok_or_else(|| {
let names: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect();
anyhow!(
"No release asset found for target '{BUILD_TARGET}'. \
Available assets: {}",
names.join(", ")
)
})?;
println!("Asset: {}", asset.name);
if !yes {
print!("Update to version {latest_version}? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Update cancelled.");
return Ok(());
}
}
// Download the asset to a temporary directory.
let tmp_dir = tempfile::Builder::new()
.prefix("mdrs-selfupdate-")
.tempdir()?;
let archive_path = tmp_dir.path().join(&asset.name);
println!("Downloading {}...", asset.browser_download_url);
let mut download_req = client
.get(&asset.browser_download_url)
.header(USER_AGENT, format!("mdrs/{current_version}"));
if let Ok(token) = env::var("GITEA_TOKEN") {
download_req = download_req.header(AUTHORIZATION, format!("Bearer {token}"));
}
let download_resp = download_req.send().await?;
if !download_resp.status().is_success() {
bail!(
"Failed to download asset: HTTP {}",
download_resp.status()
);
}
let bytes = download_resp.bytes().await?;
std::fs::write(&archive_path, &bytes)?;
// Extract the binary from the archive.
let bin_name = if cfg!(windows) { "mdrs.exe" } else { "mdrs" };
let new_bin = tmp_dir.path().join(bin_name);
let name = asset.name.as_str();
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
extract_from_tar_gz(&archive_path, bin_name, &new_bin)?;
} else if name.ends_with(".zip") {
extract_from_zip(&archive_path, bin_name, &new_bin)?;
} else {
bail!("Unsupported archive format: {}", asset.name);
}
// Make the extracted binary executable on Unix.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&new_bin)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&new_bin, perms)?;
}
// Atomically replace the current executable.
self_replace::self_replace(&new_bin)?;
println!("Successfully updated to version {latest_version}.");
Ok(())
}
+13
View File
@@ -111,6 +111,13 @@ enum Commands {
},
/// Show the version of this tool
Version,
/// Update this binary to the latest release
#[command(name = "selfupdate")]
SelfUpdate {
/// Skip the confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
/// Print the error message in Python-compatible format and exit with code 2.
@@ -349,5 +356,11 @@ fn main() {
Commands::Version => {
commands::version::version();
}
Commands::SelfUpdate { yes } => {
let rt = tokio::runtime::Runtime::new().unwrap();
if let Err(e) = rt.block_on(commands::selfupdate::selfupdate(*yes)) {
handle_error(e.into());
}
}
}
}