From baad623efec0df4bd7a724ab5c9da99c5fc9fac7 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 19 Mar 2026 11:06:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(upgrade):=20SHA-256=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81Intel=20mac=20=E4=BA=A4=E5=8F=89=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E3=80=81=E5=85=A8=E5=B9=B3=E5=8F=B0=E5=90=8E=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upgrade: 下载后校验 .sha256 摘要再安装 - workflow: ARM mac 同时产出 aarch64/x86_64 双架构,补全 Intel mac 产物 - workflow: 各平台上传主资产及 .sha256,Linux/macOS/Windows 全成功才发布 Release - upgrade: 补充 parse_tag_version、parse_checksum_file、extract_from_targz 单元测试 - docs: README/AGENTS 同步 upgrade 与平台说明 Made-with: Cursor --- .gitea/workflows/secrets.yml | 66 +++++++++--- AGENTS.md | 13 +-- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 12 ++- src/commands/upgrade.rs | 195 +++++++++++++++++++++++++++++++---- 6 files changed, 242 insertions(+), 46 deletions(-) diff --git a/.gitea/workflows/secrets.yml b/.gitea/workflows/secrets.yml index 2d6a058..066fff9 100644 --- a/.gitea/workflows/secrets.yml +++ b/.gitea/workflows/secrets.yml @@ -204,9 +204,13 @@ jobs: bin="target/x86_64-unknown-linux-musl/release/${{ env.BINARY_NAME }}" archive="${{ env.BINARY_NAME }}-${tag}-x86_64-linux-musl.tar.gz" tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" + sha256sum "$archive" > "${archive}.sha256" curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ -F "attachment=@${archive}" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" + curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@${archive}.sha256" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" - name: 飞书通知 if: always() @@ -229,7 +233,7 @@ jobs: curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL" build-macos: - name: Build (aarch64-apple-darwin) + name: Build (macOS aarch64 + x86_64) needs: [version, check] runs-on: darwin-arm64 timeout-minutes: 15 @@ -241,6 +245,7 @@ jobs: fi source "$HOME/.cargo/env" 2>/dev/null || true rustup target add aarch64-apple-darwin + rustup target add x86_64-apple-darwin echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions/checkout@v4 @@ -253,12 +258,14 @@ jobs: ~/.cargo/registry/cache ~/.cargo/git/db target - key: cargo-aarch64-apple-darwin-${{ hashFiles('Cargo.lock') }} + key: cargo-macos-${{ hashFiles('Cargo.lock') }} restore-keys: | - cargo-aarch64-apple-darwin- + cargo-macos- - run: cargo build --release --locked --target aarch64-apple-darwin + - run: cargo build --release --locked --target x86_64-apple-darwin - run: strip -x target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }} + - run: strip -x target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }} - name: 上传 Release 产物 if: needs.version.outputs.release_id != '' @@ -267,12 +274,29 @@ jobs: run: | [ -z "$RELEASE_TOKEN" ] && exit 0 tag="${{ needs.version.outputs.tag }}" - bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}" - archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz" - tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")" + release_id="${{ needs.version.outputs.release_id }}" + + arm_bin="target/aarch64-apple-darwin/release/${{ env.BINARY_NAME }}" + arm_archive="${{ env.BINARY_NAME }}-${tag}-aarch64-macos.tar.gz" + tar -czf "$arm_archive" -C "$(dirname "$arm_bin")" "$(basename "$arm_bin")" + shasum -a 256 "$arm_archive" > "${arm_archive}.sha256" curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ - -F "attachment=@${archive}" \ - "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" + -F "attachment=@${arm_archive}" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets" + curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@${arm_archive}.sha256" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets" + + intel_bin="target/x86_64-apple-darwin/release/${{ env.BINARY_NAME }}" + intel_archive="${{ env.BINARY_NAME }}-${tag}-x86_64-macos.tar.gz" + tar -czf "$intel_archive" -C "$(dirname "$intel_bin")" "$(basename "$intel_bin")" + shasum -a 256 "$intel_archive" > "${intel_archive}.sha256" + curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@${intel_archive}" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets" + curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@${intel_archive}.sha256" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${release_id}/assets" - name: 飞书通知 if: always() @@ -285,8 +309,9 @@ jobs: url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" result="${{ job.status }}" if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi - msg="secrets macOS 构建${icon} + msg="secrets macOS 双架构构建${icon} 版本:${tag} + 目标:aarch64-apple-darwin, x86_64-apple-darwin 提交:${commit} 作者:${{ github.actor }} 详情:${url}" @@ -338,10 +363,15 @@ jobs: $bin = "target\x86_64-pc-windows-msvc\release\${{ env.BINARY_NAME }}.exe" $archive = "${{ env.BINARY_NAME }}-${tag}-x86_64-windows.zip" Compress-Archive -Path $bin -DestinationPath $archive -Force + $hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower() + Set-Content -Path "${archive}.sha256" -Value "$hash $archive" -NoNewline $url = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}/assets" Invoke-RestMethod -Uri $url -Method Post ` -Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } ` -Form @{ attachment = Get-Item $archive } + Invoke-RestMethod -Uri $url -Method Post ` + -Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } ` + -Form @{ attachment = Get-Item "${archive}.sha256" } - name: 飞书通知 if: always() @@ -362,7 +392,7 @@ jobs: publish-release: name: 发布草稿 Release - needs: [version, build-linux] + needs: [version, build-linux, build-macos, build-windows] if: always() && needs.version.outputs.release_id != '' runs-on: debian timeout-minutes: 5 @@ -376,8 +406,11 @@ jobs: [ -z "$RELEASE_TOKEN" ] && exit 0 linux_r="${{ needs.build-linux.result }}" - if [ "$linux_r" != "success" ]; then - echo "Linux 构建未成功 (${linux_r}),保留草稿 Release" + macos_r="${{ needs.build-macos.result }}" + windows_r="${{ needs.build-windows.result }}" + if [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then + echo "存在未成功的构建任务,保留草稿 Release" + echo "linux=${linux_r} macos=${macos_r} windows=${windows_r}" exit 0 fi @@ -408,15 +441,16 @@ jobs: commit=$(git log -1 --pretty=format:"%s" 2>/dev/null || echo "N/A") url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}" - check_r="${{ needs.version.result }}" linux_r="${{ needs.build-linux.result }}" + macos_r="${{ needs.build-macos.result }}" + windows_r="${{ needs.build-windows.result }}" publish_r="${{ job.status }}" icon() { case "$1" in success) echo "✅";; skipped) echo "⏭";; *) echo "❌";; esac; } - if [ "$linux_r" = "success" ] && [ "$publish_r" = "success" ]; then + if [ "$linux_r" = "success" ] && [ "$macos_r" = "success" ] && [ "$windows_r" = "success" ] && [ "$publish_r" = "success" ]; then status="发布成功 ✅" - elif [ "$linux_r" != "success" ]; then + elif [ "$linux_r" != "success" ] || [ "$macos_r" != "success" ] || [ "$windows_r" != "success" ]; then status="构建失败 ❌" else status="发布失败 ❌" @@ -430,7 +464,7 @@ jobs: msg="secrets ${status} ${version_line} - linux $(icon "$linux_r") | Release $(icon "$publish_r") + linux $(icon "$linux_r") | macOS $(icon "$macos_r") | windows $(icon "$windows_r") | Release $(icon "$publish_r") 提交:${commit} 作者:${{ github.actor }} 详情:${url}" diff --git a/AGENTS.md b/AGENTS.md index ecfcb70..426a944 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ secrets/ update.rs # update 命令:增量更新,CAS 并发保护,含历史快照 rollback.rs # rollback / history 命令:版本回滚与历史查看 run.rs # inject / run 命令:临时环境变量注入 - upgrade.rs # upgrade 命令:检查并下载最新版本,自动替换二进制 + upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制 scripts/ setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets .gitea/workflows/ @@ -408,13 +408,13 @@ secrets run -n refining --kind service --name gitea -- printenv ### upgrade — 自动更新 CLI 二进制 -从 Gitea Release 下载最新版本并替换当前二进制,无需数据库连接或主密钥。 +从 Gitea Release 下载最新版本,校验对应 `.sha256` 摘要后替换当前二进制,无需数据库连接或主密钥。 ```bash # 检查是否有新版本(不下载) secrets upgrade --check -# 下载并安装最新版本 +# 下载、校验 SHA-256 并安装最新版本 secrets upgrade ``` @@ -496,10 +496,11 @@ cargo fmt -- --check && cargo clippy -- -D warnings && cargo test ## CI/CD -- Gitea Actions(runner: debian) +- Gitea Actions(runners: debian / darwin-arm64 / windows) - 触发:`src/**`、`Cargo.toml`、`Cargo.lock` 变更推送到 main -- 构建目标:`x86_64-unknown-linux-musl`(静态链接,无 glibc 依赖) -- 新版本自动打 Tag(格式 `secrets-`)并上传二进制到 Gitea Release +- 构建目标:`x86_64-unknown-linux-musl`、`aarch64-apple-darwin`、`x86_64-apple-darwin`(由 ARM mac runner 交叉编译)、`x86_64-pc-windows-msvc` +- 新版本自动打 Tag(格式 `secrets-`)并上传二进制与对应 `.sha256` 摘要到 Gitea Release +- Release 仅在 Linux/macOS/Windows 构建全部成功后才会从 draft 发布 - 通知:飞书 Webhook(`vars.WEBHOOK_URL`) - 所需 secrets/vars:`RELEASE_TOKEN`(Release 上传,Gitea PAT)、`vars.WEBHOOK_URL`(通知,可选) - **注意**:Gitea Actions 的 Secret/Variable 创建时,`data`/`value` 字段需传入**原始值**,不要使用 base64 编码 diff --git a/Cargo.lock b/Cargo.lock index 406afc6..0e54958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "sqlx", "tar", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 45e000f..46d5023 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ self-replace = "1.5.0" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha2 = "0.10.9" sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } tar = "0.4.44" tempfile = "3.19" diff --git a/README.md b/README.md index 5633bbf..9fccd95 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ cargo build --release # 或从 Release 页面下载预编译二进制 ``` -已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。 +已有旧版本时,可执行 `secrets upgrade` 自动下载最新版并替换。该命令会校验 Release 附带的 `.sha256` 摘要后再安装。 ## 首次使用(每台设备各执行一次) @@ -140,7 +140,7 @@ secrets config path # 打印配置文件路径 # ── upgrade ────────────────────────────────────────────────────────────────── secrets upgrade --check # 仅检查是否有新版本 -secrets upgrade # 下载并安装最新版(从 Gitea Release) +secrets upgrade # 下载、校验 SHA-256 并安装最新版(从 Gitea Release) # ── 调试 ────────────────────────────────────────────────────────────────────── secrets --verbose search -q mqtt @@ -199,7 +199,7 @@ scripts/ ## CI/CD(Gitea Actions) -推送 `main` 分支时自动:fmt/clippy 检查 → musl 构建 → 创建 Release 并上传二进制。 +推送 `main` 分支时自动:fmt/clippy/test 检查 → Linux/macOS/Windows 构建 → 上传二进制与 `.sha256` 摘要 → 所有平台成功后发布 Release。 **首次使用需配置 Actions 变量和 Secrets:** @@ -212,4 +212,10 @@ scripts/ - `WEBHOOK_URL`(Variable):飞书通知,可选 - **注意**:Secret/Variable 的 `data`/`value` 字段需传入原始值,不要 base64 编码 +当前 Release 预编译产物覆盖: +- Linux `x86_64-unknown-linux-musl` +- macOS Apple Silicon `aarch64-apple-darwin` +- macOS Intel `x86_64-apple-darwin`(由 ARM mac runner 交叉编译) +- Windows `x86_64-pc-windows-msvc` + 详见 [AGENTS.md](AGENTS.md)。 diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index a6794ea..51e7a79 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, bail}; use flate2::read::GzDecoder; use serde::Deserialize; +use sha2::{Digest, Sha256}; use std::io::{Cursor, Read, Write}; const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest"; @@ -19,6 +20,26 @@ struct Asset { browser_download_url: String, } +fn available_assets(assets: &[Asset]) -> String { + assets + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") +} + +fn find_asset_by_suffix<'a>(assets: &'a [Asset], suffix: &str) -> Result<&'a Asset> { + assets + .iter() + .find(|a| a.name.ends_with(suffix)) + .with_context(|| { + format!( + "no asset found for this platform (looking for suffix: {suffix})\navailable: {}", + available_assets(assets) + ) + }) +} + /// Detect the asset suffix for the current platform/arch at compile time. fn platform_asset_suffix() -> Result<&'static str> { #[cfg(all(target_os = "linux", target_arch = "x86_64"))] @@ -63,6 +84,40 @@ fn parse_tag_version(tag: &str) -> Result { .with_context(|| format!("failed to parse version from tag: {tag}")) } +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!("{digest:x}") +} + +fn parse_checksum_file(contents: &str) -> Result { + let checksum = contents + .split_whitespace() + .next() + .context("checksum file is empty")? + .trim() + .to_ascii_lowercase(); + + if checksum.len() != 64 || !checksum.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("invalid SHA-256 checksum format") + } + + Ok(checksum) +} + +async fn download_bytes(client: &reqwest::Client, url: &str, context: &str) -> Result> { + Ok(client + .get(url) + .send() + .await + .with_context(|| format!("{context}: request failed"))? + .error_for_status() + .with_context(|| format!("{context}: server returned an error"))? + .bytes() + .await + .with_context(|| format!("{context}: failed to read response body"))? + .to_vec()) +} + /// Extract the binary from a tar.gz archive (first file whose name == "secrets"). fn extract_from_targz(bytes: &[u8]) -> Result> { let gz = GzDecoder::new(Cursor::new(bytes)); @@ -137,43 +192,52 @@ pub async fn run(check_only: bool) -> Result<()> { } let suffix = platform_asset_suffix()?; - let asset = release + let asset = find_asset_by_suffix(&release.assets, suffix)?; + let checksum_name = format!("{}.sha256", asset.name); + let checksum_asset = release .assets .iter() - .find(|a| a.name.ends_with(suffix)) + .find(|a| a.name == checksum_name) .with_context(|| { format!( - "no asset found for this platform (looking for suffix: {suffix})\navailable: {}", - release - .assets - .iter() - .map(|a| a.name.as_str()) - .collect::>() - .join(", ") + "missing checksum asset for download: {checksum_name}\navailable: {}", + available_assets(&release.assets) ) })?; println!("Downloading {}...", asset.name); - let bytes = client - .get(&asset.browser_download_url) - .send() - .await - .context("download failed")? - .error_for_status() - .context("download returned an error")? - .bytes() - .await - .context("failed to read download body")?; + let archive = download_bytes(&client, &asset.browser_download_url, "archive download").await?; + let checksum_contents = download_bytes( + &client, + &checksum_asset.browser_download_url, + "checksum download", + ) + .await?; + let expected_checksum = parse_checksum_file( + std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?, + )?; + let actual_checksum = sha256_hex(&archive); + + if actual_checksum != expected_checksum { + bail!( + "checksum verification failed for {}: expected {}, got {}", + asset.name, + expected_checksum, + actual_checksum + ); + } + + println!("Verified SHA-256: {actual_checksum}"); println!("Extracting..."); let binary = if suffix.ends_with(".tar.gz") { - extract_from_targz(&bytes)? + extract_from_targz(&archive)? } else { #[cfg(target_os = "windows")] { - extract_from_zip(&bytes)? + extract_from_zip(&archive)? } #[cfg(not(target_os = "windows"))] bail!("zip extraction is only supported on Windows") @@ -196,3 +260,92 @@ pub async fn run(check_only: bool) -> Result<()> { println!("Updated: v{current} → v{latest}"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use tar::Builder; + + #[test] + fn parse_tag_version_accepts_release_tag() { + let version = parse_tag_version("secrets-0.6.1").expect("version should parse"); + assert_eq!(version, semver::Version::new(0, 6, 1)); + } + + #[test] + fn parse_tag_version_rejects_invalid_tag() { + let err = parse_tag_version("v0.6.1").expect_err("tag should be rejected"); + assert!(err.to_string().contains("unexpected tag format")); + } + + #[test] + fn parse_checksum_file_accepts_sha256sum_format() { + let checksum = parse_checksum_file( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz", + ) + .expect("checksum should parse"); + assert_eq!( + checksum, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + } + + #[test] + fn parse_checksum_file_rejects_invalid_checksum() { + let err = parse_checksum_file("not-a-sha256").expect_err("checksum should be rejected"); + assert!(err.to_string().contains("invalid SHA-256 checksum format")); + } + + #[test] + fn sha256_hex_matches_known_value() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn extract_from_targz_reads_binary() { + let payload = b"fake-secrets-binary"; + let archive = make_test_targz("secrets", payload); + let extracted = extract_from_targz(&archive).expect("binary should extract"); + assert_eq!(extracted, payload); + } + + fn make_test_targz(name: &str, payload: &[u8]) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = Builder::new(encoder); + + let mut header = tar::Header::new_gnu(); + header.set_mode(0o755); + header.set_size(payload.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, name, payload) + .expect("append tar entry"); + + let encoder = builder.into_inner().expect("finish tar builder"); + encoder.finish().expect("finish gzip") + } + + #[cfg(target_os = "windows")] + #[test] + fn extract_from_zip_reads_binary() { + use zip::write::SimpleFileOptions; + + let cursor = Cursor::new(Vec::::new()); + let mut writer = zip::ZipWriter::new(cursor); + writer + .start_file("secrets.exe", SimpleFileOptions::default()) + .expect("start zip file"); + writer + .write_all(b"fake-secrets-binary") + .expect("write zip payload"); + let bytes = writer.finish().expect("finish zip").into_inner(); + + let extracted = extract_from_zip(&bytes).expect("binary should extract"); + assert_eq!(extracted, b"fake-secrets-binary"); + } +}