Update from Gitea release assets
CI / test (push) Successful in 35s
Release / release (push) Successful in 35s

This commit is contained in:
2026-06-09 16:07:46 +09:00
parent edf409bdaa
commit ab46799818
6 changed files with 253 additions and 41 deletions
+216 -22
View File
@@ -2,11 +2,14 @@ use crate::core::output;
use std::path::PathBuf;
use std::process::Command;
const DEFAULT_INSTALL_SCRIPT_URL: &str =
"https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh";
use crate::UpdateCommand;
pub fn handle_update() {
match update() {
const DEFAULT_RELEASE_API_URL: &str =
"https://git.hananakick.cc/api/v1/repos/Autotrade/openstock/releases/latest";
const DEFAULT_ASSET_SUFFIX: &str = "-linux-x86_64.tar.gz";
pub fn handle_update(command: &UpdateCommand) {
match update(command.force) {
Ok(result) => {
println!(
"{}",
@@ -15,9 +18,39 @@ pub fn handle_update() {
"openstock 바이너리 업데이트 실행 결과",
vec![
output::field(
"installer_url",
"업데이트에 사용한 원격 설치 스크립트 URL",
serde_json::json!(result.installer_url),
"release_api_url",
"최신 release 정보를 조회한 Gitea API URL",
serde_json::json!(result.release_api_url),
),
output::field(
"current_version",
"현재 실행 중인 openstock 버전",
serde_json::json!(result.current_version),
),
output::field(
"latest_version",
"Gitea 최신 release tag에서 해석한 버전",
serde_json::json!(result.latest_version),
),
output::field(
"release_tag",
"Gitea 최신 release tag",
serde_json::json!(result.release_tag),
),
output::field(
"release_url",
"Gitea release 페이지 URL",
serde_json::json!(result.release_url),
),
output::field(
"asset_name",
"설치에 사용한 release asset 이름",
serde_json::json!(result.asset_name),
),
output::field(
"asset_url",
"설치에 사용한 release asset 다운로드 URL",
serde_json::json!(result.asset_url),
),
output::field(
"install_dir",
@@ -27,16 +60,16 @@ pub fn handle_update() {
output::field(
"status",
"업데이트 명령 실행 결과",
serde_json::json!("updated"),
serde_json::json!(result.status),
),
output::field(
"stdout",
"설치 스크립트 표준 출력",
"release asset 설치 스크립트 표준 출력",
serde_json::json!(result.stdout),
),
output::field(
"stderr",
"설치 스크립트 표준 오류 출력",
"release asset 설치 스크립트 표준 오류 출력",
serde_json::json!(result.stderr),
),
],
@@ -50,35 +83,154 @@ pub fn handle_update() {
}
}
fn update() -> Result<UpdateResult, String> {
fn update(force: bool) -> Result<UpdateResult, String> {
let current_version = env!("CARGO_PKG_VERSION").to_string();
let release_api_url = release_api_url();
let release = latest_release(&release_api_url)?;
let latest_version = normalize_version(&release.tag_name);
let asset = release_asset(&release)?;
let install_dir = install_dir()?;
let installer_url = installer_url();
let command = format!("curl -fsSL {} | sh", shell_quote(&installer_url));
if !force && compare_versions(&latest_version, &current_version) != std::cmp::Ordering::Greater
{
return Ok(UpdateResult {
release_api_url,
current_version,
latest_version,
release_tag: release.tag_name,
release_url: release.html_url,
asset_name: asset.name,
asset_url: asset.browser_download_url,
install_dir,
status: "up_to_date".to_string(),
stdout: String::new(),
stderr: String::new(),
});
}
let script = format!(
"set -eu\n\
tmp=\"$(mktemp -d \"${{TMPDIR:-/tmp}}/openstock-update.XXXXXX\")\"\n\
cleanup() {{ rm -rf \"$tmp\"; }}\n\
trap cleanup EXIT\n\
curl -fsSL {} -o \"$tmp/openstock.tar.gz\"\n\
tar -xzf \"$tmp/openstock.tar.gz\" -C \"$tmp\"\n\
test -x \"$tmp/openstock\"\n\
mkdir -p {}\n\
target={}\n\
tmp_target=\"{}/.openstock.tmp.$$\"\n\
cp \"$tmp/openstock\" \"$tmp_target\"\n\
chmod 755 \"$tmp_target\"\n\
mv -f \"$tmp_target\" \"$target\"\n\
printf 'installed: %s\\n' \"$target\"\n",
shell_quote(&asset.browser_download_url),
shell_quote(&install_dir.display().to_string()),
shell_quote(&install_dir.join("openstock").display().to_string()),
shell_quote(&install_dir.display().to_string()),
);
let output = Command::new("sh")
.arg("-c")
.arg(command)
.env("OPENSTOCK_INSTALL_DIR", &install_dir)
.arg(script)
.output()
.map_err(|err| format!("업데이트 스크립트 실행 실패: {}", err))?;
.map_err(|err| format!("release asset 설치 스크립트 실행 실패: {}", err))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!(
"업데이트 스크립트가 실패했습니다. status={}, stdout={}, stderr={}",
"release asset 설치 스크립트가 실패했습니다. status={}, stdout={}, stderr={}",
output.status, stdout, stderr
));
}
Ok(UpdateResult {
installer_url,
release_api_url,
current_version,
latest_version,
release_tag: release.tag_name,
release_url: release.html_url,
asset_name: asset.name,
asset_url: asset.browser_download_url,
install_dir,
status: "updated".to_string(),
stdout,
stderr,
})
}
fn latest_release(api_url: &str) -> Result<Release, String> {
let response = crate::core::http::agent()
.get(api_url)
.call()
.map_err(|err| format!("최신 release 조회 실패: {}", err))?;
let status = response.status();
let body = response
.into_body()
.read_to_string()
.map_err(|err| format!("최신 release 응답 읽기 실패: {}", err))?;
if !status.is_success() {
return Err(format!("최신 release 조회 오류 ({}): {}", status, body));
}
let value = serde_json::from_str::<serde_json::Value>(&body)
.map_err(|err| format!("최신 release 응답 파싱 실패: {} / 원문: {}", err, body))?;
let tag_name = value
.get("tag_name")
.and_then(|value| value.as_str())
.ok_or_else(|| format!("최신 release 응답에 tag_name이 없습니다: {}", body))?
.to_string();
let html_url = value
.get("html_url")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
let assets = value
.get("assets")
.and_then(|value| value.as_array())
.ok_or_else(|| format!("최신 release 응답에 assets가 없습니다: {}", body))?
.iter()
.filter_map(|asset| {
let name = asset.get("name")?.as_str()?.to_string();
let browser_download_url = asset
.get("browser_download_url")
.or_else(|| asset.get("download_url"))?
.as_str()?
.to_string();
Some(ReleaseAsset {
name,
browser_download_url,
})
})
.collect::<Vec<_>>();
Ok(Release {
tag_name,
html_url,
assets,
})
}
fn release_asset(release: &Release) -> Result<ReleaseAsset, String> {
let suffix = std::env::var("OPENSTOCK_RELEASE_ASSET_SUFFIX")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_ASSET_SUFFIX.to_string());
release
.assets
.iter()
.find(|asset| asset.name.ends_with(&suffix) && !asset.name.ends_with(".sha256"))
.cloned()
.ok_or_else(|| {
format!(
"release {}에서 suffix '{}'에 맞는 바이너리 asset을 찾지 못했습니다",
release.tag_name, suffix
)
})
}
fn install_dir() -> Result<PathBuf, String> {
if let Some(value) = std::env::var_os("OPENSTOCK_INSTALL_DIR") {
return Ok(PathBuf::from(value));
@@ -94,20 +246,62 @@ fn install_dir() -> Result<PathBuf, String> {
})
}
fn installer_url() -> String {
std::env::var("OPENSTOCK_INSTALL_SCRIPT_URL")
fn release_api_url() -> String {
std::env::var("OPENSTOCK_RELEASE_API_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_INSTALL_SCRIPT_URL.to_string())
.unwrap_or_else(|| DEFAULT_RELEASE_API_URL.to_string())
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn normalize_version(tag: &str) -> String {
tag.trim().trim_start_matches('v').to_string()
}
fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
let left_parts = version_parts(left);
let right_parts = version_parts(right);
left_parts.cmp(&right_parts)
}
fn version_parts(version: &str) -> Vec<u64> {
version
.split(|ch| ch == '.' || ch == '-' || ch == '+')
.map(|part| {
part.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>()
.parse::<u64>()
.unwrap_or(0)
})
.collect()
}
struct Release {
tag_name: String,
html_url: String,
assets: Vec<ReleaseAsset>,
}
#[derive(Clone)]
struct ReleaseAsset {
name: String,
browser_download_url: String,
}
struct UpdateResult {
installer_url: String,
release_api_url: String,
current_version: String,
latest_version: String,
release_tag: String,
release_url: String,
asset_name: String,
asset_url: String,
install_dir: PathBuf,
status: String,
stdout: String,
stderr: String,
}
+10 -3
View File
@@ -1,4 +1,4 @@
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
use commands::account::AccountCommands;
use commands::api::ApiCommands;
use commands::cache::CacheCommands;
@@ -30,7 +30,7 @@ enum Commands {
Version,
/// 설치된 openstock 바이너리 업데이트
Update,
Update(UpdateCommand),
/// 증권사 API 리스트 조회 및 API 설정
Api {
@@ -83,7 +83,7 @@ fn main() {
match &cli.command {
Some(Commands::Version) => commands::handle_version(),
Some(Commands::Update) => commands::handle_update(),
Some(Commands::Update(command)) => commands::handle_update(command),
Some(Commands::Api { sub }) => commands::handle_api(sub),
Some(Commands::Dart { sub }) => commands::handle_dart(sub),
Some(Commands::Account { sub }) => commands::handle_account(sub),
@@ -115,3 +115,10 @@ fn main() {
}
}
}
#[derive(Args)]
struct UpdateCommand {
/// 현재 버전과 같아도 최신 release asset으로 재설치
#[arg(long)]
force: bool,
}