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
Generated
+1 -1
View File
@@ -667,7 +667,7 @@ dependencies = [
[[package]] [[package]]
name = "openstock" name = "openstock"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"clap", "clap",
"encoding_rs", "encoding_rs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "openstock" name = "openstock"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
+18 -12
View File
@@ -50,15 +50,15 @@ openstock update
curl -fsSL https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/update.sh | sh curl -fsSL https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/update.sh | sh
``` ```
`openstock update`현재 실행 중인 바이너리의 디렉터리를 `OPENSTOCK_INSTALL_DIR`로 사용해 설치 스크립트를 다시 실행합니다. 설치 위치를 직접 지정하려면 `OPENSTOCK_INSTALL_DIR=/path openstock update`를 사용합니다. 설치 스크립트 URL은 `OPENSTOCK_INSTALL_SCRIPT_URL`로 바꿀 수 있습니다. `openstock update`Gitea 최신 release를 조회하고, 현재 버전보다 최신이면 Linux x86_64 release asset을 내려받아 현재 실행 중인 바이너리의 디렉터리에 설치합니다. 설치 위치를 직접 지정하려면 `OPENSTOCK_INSTALL_DIR=/path openstock update`를 사용합니다. 같은 버전도 재설치하려면 `openstock update --force`를 사용합니다.
## Release ## Release
Gitea Actions가 활성화된 저장소에서는 push 시 자동으로 CI가 실행됩니다. `main` branch push는 테스트와 release 빌드 검증만 수행하고, `v*` tag push는 Gitea Release를 만들고 Linux x86_64 바이너리를 asset으로 등록합니다. Gitea Actions가 활성화된 저장소에서는 push 시 자동으로 CI가 실행됩니다. `main` branch push는 테스트와 release 빌드 검증만 수행하고, `v*` tag push는 Gitea Release를 만들고 Linux x86_64 바이너리를 asset으로 등록합니다.
```bash ```bash
git tag v0.1.0 git tag v0.2.0
git push origin main v0.1.0 git push origin main v0.2.0
``` ```
필요 조건: 필요 조건:
@@ -187,11 +187,17 @@ docker compose logs --tail=120 openstock-runner
| Field | Type | Meaning | | Field | Type | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `installer_url` | string | 업데이트에 사용한 원격 설치 스크립트 URL입니다. | | `release_api_url` | string | 최신 release 정보를 조회한 Gitea API URL입니다. |
| `current_version` | string | 현재 실행 중인 openstock 버전입니다. |
| `latest_version` | string | Gitea 최신 release tag에서 해석한 버전입니다. |
| `release_tag` | string | Gitea 최신 release tag입니다. |
| `release_url` | string | Gitea release 페이지 URL입니다. |
| `asset_name` | string | 설치에 사용한 release asset 이름입니다. |
| `asset_url` | string | 설치에 사용한 release asset 다운로드 URL입니다. |
| `install_dir` | string | 업데이트 대상 바이너리를 설치한 디렉터리입니다. | | `install_dir` | string | 업데이트 대상 바이너리를 설치한 디렉터리입니다. |
| `status` | string | 업데이트 명령 실행 결과입니다. 성공 시 `updated`입니다. | | `status` | string | 업데이트 명령 실행 결과입니다. `updated` 또는 `up_to_date`입니다. |
| `stdout` | string | 설치 스크립트의 표준 출력입니다. | | `stdout` | string | release asset 설치 스크립트의 표준 출력입니다. |
| `stderr` | string | 설치 스크립트의 표준 오류 출력입니다. | | `stderr` | string | release asset 설치 스크립트의 표준 오류 출력입니다. |
#### `api list` #### `api list`
@@ -580,16 +586,16 @@ openstock cache prune
### `openstock update` ### `openstock update`
원격 설치 스크립트를 다시 실행해 현재 openstock 바이너리를 업데이트합니다. Gitea 최신 release asset을 내려받아 현재 openstock 바이너리를 업데이트합니다.
| Direction | Data | | Direction | Data |
| --- | --- | | --- | --- |
| Input | `OPENSTOCK_INSTALL_DIR`, `OPENSTOCK_INSTALL_SCRIPT_URL` 선택 지정 | | Input | `--force`; `OPENSTOCK_INSTALL_DIR`, `OPENSTOCK_RELEASE_API_URL`, `OPENSTOCK_RELEASE_ASSET_SUFFIX` 선택 지정 |
| External IO | `GET https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh`; install script가 source archive를 내려받을 수 있음 | | External IO | `GET https://git.hananakick.cc/api/v1/repos/Autotrade/openstock/releases/latest`; selected release asset download |
| File write | 현재 실행 바이너리 디렉터리 또는 `OPENSTOCK_INSTALL_DIR``openstock` 바이너리 | | File write | 현재 실행 바이너리 디렉터리 또는 `OPENSTOCK_INSTALL_DIR``openstock` 바이너리 |
| Output fields | `installer_url`, `install_dir`, `status`, `stdout`, `stderr` | | Output fields | `release_api_url`, `current_version`, `latest_version`, `release_tag`, `release_url`, `asset_name`, `asset_url`, `install_dir`, `status`, `stdout`, `stderr` |
| Raw | `null` | | Raw | `null` |
| Side effect | release build 후 설치된 바이너리를 교체합니다. `~/.config/openstock` 설정/캐시는 보존합니다. | | Side effect | 최신 release 버전이 현재 버전보다 크거나 `--force`이면 설치된 바이너리를 교체합니다. `~/.config/openstock` 설정/캐시는 보존합니다. |
### `openstock api list` ### `openstock api list`
+7 -2
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
SCRIPT_URL="${OPENSTOCK_INSTALL_SCRIPT_URL:-https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh}" BIN_NAME="${OPENSTOCK_BIN_NAME:-openstock}"
curl -fsSL "$SCRIPT_URL" | sh if ! command -v "$BIN_NAME" >/dev/null 2>&1; then
echo "$BIN_NAME is required for update. Install first with scripts/install.sh." >&2
exit 1
fi
"$BIN_NAME" update "$@"
+216 -22
View File
@@ -2,11 +2,14 @@ use crate::core::output;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
const DEFAULT_INSTALL_SCRIPT_URL: &str = use crate::UpdateCommand;
"https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh";
pub fn handle_update() { const DEFAULT_RELEASE_API_URL: &str =
match update() { "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) => { Ok(result) => {
println!( println!(
"{}", "{}",
@@ -15,9 +18,39 @@ pub fn handle_update() {
"openstock 바이너리 업데이트 실행 결과", "openstock 바이너리 업데이트 실행 결과",
vec![ vec![
output::field( output::field(
"installer_url", "release_api_url",
"업데이트에 사용한 원격 설치 스크립트 URL", "최신 release 정보를 조회한 Gitea API URL",
serde_json::json!(result.installer_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( output::field(
"install_dir", "install_dir",
@@ -27,16 +60,16 @@ pub fn handle_update() {
output::field( output::field(
"status", "status",
"업데이트 명령 실행 결과", "업데이트 명령 실행 결과",
serde_json::json!("updated"), serde_json::json!(result.status),
), ),
output::field( output::field(
"stdout", "stdout",
"설치 스크립트 표준 출력", "release asset 설치 스크립트 표준 출력",
serde_json::json!(result.stdout), serde_json::json!(result.stdout),
), ),
output::field( output::field(
"stderr", "stderr",
"설치 스크립트 표준 오류 출력", "release asset 설치 스크립트 표준 오류 출력",
serde_json::json!(result.stderr), 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 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") let output = Command::new("sh")
.arg("-c") .arg("-c")
.arg(command) .arg(script)
.env("OPENSTOCK_INSTALL_DIR", &install_dir)
.output() .output()
.map_err(|err| format!("업데이트 스크립트 실행 실패: {}", err))?; .map_err(|err| format!("release asset 설치 스크립트 실행 실패: {}", err))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() { if !output.status.success() {
return Err(format!( return Err(format!(
"업데이트 스크립트가 실패했습니다. status={}, stdout={}, stderr={}", "release asset 설치 스크립트가 실패했습니다. status={}, stdout={}, stderr={}",
output.status, stdout, stderr output.status, stdout, stderr
)); ));
} }
Ok(UpdateResult { 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, install_dir,
status: "updated".to_string(),
stdout, stdout,
stderr, 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> { fn install_dir() -> Result<PathBuf, String> {
if let Some(value) = std::env::var_os("OPENSTOCK_INSTALL_DIR") { if let Some(value) = std::env::var_os("OPENSTOCK_INSTALL_DIR") {
return Ok(PathBuf::from(value)); return Ok(PathBuf::from(value));
@@ -94,20 +246,62 @@ fn install_dir() -> Result<PathBuf, String> {
}) })
} }
fn installer_url() -> String { fn release_api_url() -> String {
std::env::var("OPENSTOCK_INSTALL_SCRIPT_URL") std::env::var("OPENSTOCK_RELEASE_API_URL")
.ok() .ok()
.filter(|value| !value.trim().is_empty()) .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 { fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''")) 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 { 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, install_dir: PathBuf,
status: String,
stdout: String, stdout: String,
stderr: 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::account::AccountCommands;
use commands::api::ApiCommands; use commands::api::ApiCommands;
use commands::cache::CacheCommands; use commands::cache::CacheCommands;
@@ -30,7 +30,7 @@ enum Commands {
Version, Version,
/// 설치된 openstock 바이너리 업데이트 /// 설치된 openstock 바이너리 업데이트
Update, Update(UpdateCommand),
/// 증권사 API 리스트 조회 및 API 설정 /// 증권사 API 리스트 조회 및 API 설정
Api { Api {
@@ -83,7 +83,7 @@ fn main() {
match &cli.command { match &cli.command {
Some(Commands::Version) => commands::handle_version(), 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::Api { sub }) => commands::handle_api(sub),
Some(Commands::Dart { sub }) => commands::handle_dart(sub), Some(Commands::Dart { sub }) => commands::handle_dart(sub),
Some(Commands::Account { sub }) => commands::handle_account(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,
}