From 1b399a58d091e583e382e2ee79d03c08f2085f87 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 22 Jul 2026 11:17:29 +0800 Subject: [PATCH] fix(updater): sync release notes across update sources --- .github/scripts/sync-changelog.mjs | 15 ++++ .github/scripts/sync-changelog.test.mjs | 28 ++++++ .github/workflows/sync-changelog.yml | 39 +++++++++ apps/desktop/src/composables/useAppUpdater.ts | 2 +- apps/desktop/src/lib/backend/http.ts | 7 +- apps/desktop/src/lib/backend/tauri.ts | 4 +- crates/dbx-core/src/jdbc.rs | 2 +- crates/dbx-core/src/lib.rs | 12 ++- crates/dbx-core/src/update.rs | 87 +++++++++++++++++-- crates/dbx-web/src/routes/update.rs | 4 +- src-tauri/src/commands/update.rs | 7 +- 11 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/sync-changelog.test.mjs diff --git a/.github/scripts/sync-changelog.mjs b/.github/scripts/sync-changelog.mjs index 0676312bd8..28e1918d15 100644 --- a/.github/scripts/sync-changelog.mjs +++ b/.github/scripts/sync-changelog.mjs @@ -11,6 +11,7 @@ const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || ""; const OUT_CN = "releases-cn.json"; const OUT_EN = "releases-en.json"; const LATEST_EN_OUT = "latest-en.json"; +const LATEST_NOTES_OUT = "latest-notes.json"; const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json"; const SECTION_MAP = { @@ -156,6 +157,14 @@ function buildLatestEnNotes(enReleasesJson) { }; } +export function buildLatestReleaseNotes(releases) { + const latest = releases + .filter((release) => !release.draft && !release.prerelease && !release.tag_name.startsWith("agents-")) + .sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0]; + if (!latest) return null; + return { version: latest.tag_name, notes: latest.body || "" }; +} + export async function fetchCachedEnglish({ cacheUrl = EN_CACHE_URL, fetchImpl = fetch } = {}) { try { const res = await fetchImpl(cacheUrl, { headers: { Accept: "application/json" } }); @@ -262,6 +271,12 @@ async function main() { writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2)); console.log(`Wrote ${OUT_CN}`); + const latestNotes = buildLatestReleaseNotes(releases); + if (latestNotes) { + writeFileSync(LATEST_NOTES_OUT, JSON.stringify(latestNotes, null, 2)); + console.log(`Wrote ${LATEST_NOTES_OUT}`); + } + console.log("Fetching cached English changelog..."); const cachedEnJson = await fetchCachedEnglish(); diff --git a/.github/scripts/sync-changelog.test.mjs b/.github/scripts/sync-changelog.test.mjs new file mode 100644 index 0000000000..c279388394 --- /dev/null +++ b/.github/scripts/sync-changelog.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildLatestReleaseNotes } from "./sync-changelog.mjs"; + +test("buildLatestReleaseNotes returns the curated latest release body", () => { + const result = buildLatestReleaseNotes([ + { + tag_name: "v0.5.62", + draft: false, + prerelease: false, + published_at: "2026-07-20T00:00:00Z", + body: "### 新功能\n- old\n\n### 下载安装\n- assets", + }, + { + tag_name: "v0.5.63", + draft: false, + prerelease: false, + published_at: "2026-07-21T00:00:00Z", + body: "### 新功能\n- new\n\n### 下载安装\n- assets", + }, + ]); + + assert.deepEqual(result, { + version: "v0.5.63", + notes: "### 新功能\n- new\n\n### 下载安装\n- assets", + }); +}); diff --git a/.github/workflows/sync-changelog.yml b/.github/workflows/sync-changelog.yml index 009d02d4fa..cdc212531e 100644 --- a/.github/workflows/sync-changelog.yml +++ b/.github/workflows/sync-changelog.yml @@ -39,8 +39,47 @@ jobs: --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ --content-type application/json fi + aws s3 cp latest-notes.json "s3://${R2_BUCKET_NAME}/changelog/latest-notes.json" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} + + - name: Synchronize updater notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} + run: | + set -euo pipefail + TAG="$(jq -r '.version' latest-notes.json)" + mkdir -p updater-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern latest.json --dir updater-assets --clobber + NOTES="$(jq -r '.notes' latest-notes.json)" + jq --arg notes "$NOTES" '.notes = $notes' updater-assets/latest.json > updater-assets/latest.json.tmp + mv updater-assets/latest.json.tmp updater-assets/latest.json + gh release upload "$TAG" updater-assets/latest.json --repo "$GITHUB_REPOSITORY" --clobber + + mkdir -p cnb-assets + cp updater-assets/latest.json cnb-assets/latest.json + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft > updater-release.json + node .github/scripts/sync-cnb-release.mjs \ + --github-release updater-release.json \ + --assets-dir cnb-assets \ + --overwrite-existing + + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + aws s3 cp "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" r2-latest.json --endpoint-url "$ENDPOINT" + jq --arg notes "$NOTES" '.notes = $notes' r2-latest.json > r2-latest.json.tmp + mv r2-latest.json.tmp r2-latest.json + aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/${TAG}/latest.json" \ + --endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache + aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" \ + --endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts index d6d65e1366..6ea017e6ec 100644 --- a/apps/desktop/src/composables/useAppUpdater.ts +++ b/apps/desktop/src/composables/useAppUpdater.ts @@ -82,7 +82,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) { checkingUpdates.value = true; updateCheckMessage.value = ""; try { - const info = await api.checkForUpdates(currentLocale()); + const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource)); updateInfo.value = info; if (info.update_available) { if (shouldOpenUpdateDialog({ silent: options.silent })) { diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 82231e22d5..138cb5c039 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2272,8 +2272,11 @@ export async function deleteHistoryEntry(id: string): Promise { // Updates // --------------------------------------------------------------------------- -export async function checkForUpdates(locale?: string): Promise { - const query = locale ? `?locale=${encodeURIComponent(locale)}` : ""; +export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise { + const params = new URLSearchParams(); + if (locale) params.set("locale", locale); + if (source) params.set("source", source); + const query = params.size > 0 ? `?${params.toString()}` : ""; return get(`/api/update/check${query}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index e35a0c66a9..ae21d8ab5a 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1463,8 +1463,8 @@ export async function installMcpServer(): Promise { return invoke("install_mcp_server"); } -export async function checkForUpdates(locale?: string): Promise { - return invoke("check_for_updates", { locale }); +export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise { + return invoke("check_for_updates", { locale, source }); } export async function fetchChangelog(lang?: string): Promise { diff --git a/crates/dbx-core/src/jdbc.rs b/crates/dbx-core/src/jdbc.rs index a5778c0a27..fee44fd864 100644 --- a/crates/dbx-core/src/jdbc.rs +++ b/crates/dbx-core/src/jdbc.rs @@ -403,7 +403,7 @@ fn build_plugin_status( async fn latest_jdbc_plugin() -> Option { // 只需 jdbc_plugin,不关心 release notes;传中文 locale 跳过英文 notes 拉取 - fetch_latest_release("zh-CN").await.ok().and_then(|release| release.jdbc_plugin) + fetch_latest_release("zh-CN", crate::DownloadSource::Official).await.ok().and_then(|release| release.jdbc_plugin) } async fn download_jdbc_plugin_zip_with_progress(progress: &impl Fn(AgentProgressEvent)) -> Result, String> { diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index f778a125a7..998a0a324e 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -130,18 +130,26 @@ pub async fn race_download( github_url: &str, r2_path: &str, user_agent: &str, +) -> Result { + race_download_urls(client, &download_candidate_urls(github_url, r2_path), user_agent).await +} + +pub async fn race_download_urls( + client: &reqwest::Client, + urls: &[String], + user_agent: &str, ) -> Result { use futures::future::select_ok; - let urls = download_candidate_urls(github_url, r2_path); let mut futs: Vec = Vec::with_capacity(urls.len()); for url in urls { let client = client.clone(); + let url = url.clone(); let ua = user_agent.to_string(); futs.push(Box::pin(async move { client - .get(&url) + .get(url) .header(reqwest::header::USER_AGENT, ua) .header(reqwest::header::ACCEPT_ENCODING, "identity") .send() diff --git a/crates/dbx-core/src/update.rs b/crates/dbx-core/src/update.rs index 7146985296..60392e4bf7 100644 --- a/crates/dbx-core/src/update.rs +++ b/crates/dbx-core/src/update.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; -const LATEST_JSON_PATH: &str = "https://github.com/t8y2/dbx/releases/latest/download/latest.json"; +const LATEST_JSON_GITHUB_PATH: &str = "https://github.com/t8y2/dbx/releases/latest/download/latest.json"; const LATEST_JSON_R2_PATH: &str = "releases/latest/latest.json"; +const LATEST_JSON_CNB_PATH: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/latest/download/latest.json"; const LATEST_EN_NOTES_R2_PATH: &str = "changelog/latest-en.json"; const GITHUB_RELEASE_API_PREFIX: &str = "https://api.github.com/repos/t8y2/dbx/releases/tags/v"; const RELEASE_URL_PREFIX: &str = "https://github.com/t8y2/dbx/releases/tag/v"; @@ -46,12 +47,11 @@ pub struct UpdateInfo { pub release_notes: String, } -pub async fn fetch_latest_release(locale: &str) -> Result { +pub async fn fetch_latest_release(locale: &str, source: crate::DownloadSource) -> Result { let client = build_update_http_client()?; - let resp = crate::race_download(&client, LATEST_JSON_PATH, LATEST_JSON_R2_PATH, "dbx-update-checker") - .await - .map_err(|e| format!("Failed to check updates: {e}"))?; + let candidates = update_check_candidates(source); + let resp = fetch_first_available(&client, &candidates).await?; let mut release = resp.json::().await.map_err(|e| format!("Failed to parse update response: {e}"))?; if let Ok(github) = fetch_github_release_metadata(&client, &release.version).await { @@ -66,6 +66,38 @@ pub async fn fetch_latest_release(locale: &str) -> Result Ok(release) } +async fn fetch_first_available(client: &reqwest::Client, candidates: &[String]) -> Result { + let mut errors = Vec::with_capacity(candidates.len()); + for url in candidates { + match client + .get(url) + .header(reqwest::header::USER_AGENT, "dbx-update-checker") + .header(reqwest::header::ACCEPT_ENCODING, "identity") + .send() + .await + .and_then(|response| response.error_for_status()) + { + Ok(response) => return Ok(response), + Err(error) => errors.push(format!("{url}: {error}")), + } + } + Err(format!("Failed to check updates: {}", errors.join("; "))) +} + +fn update_check_candidates(source: crate::DownloadSource) -> Vec { + match source { + crate::DownloadSource::Official => { + vec![format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE), LATEST_JSON_GITHUB_PATH.to_string()] + } + // CNB exposes a moving latest release, so checking CNB does not need an official-source version first. + crate::DownloadSource::Cnb => vec![ + LATEST_JSON_CNB_PATH.to_string(), + format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE), + LATEST_JSON_GITHUB_PATH.to_string(), + ], + } +} + // 拉取 R2 上的英文 release notes(仅最新版本)。version 必须与 latest.json 的 version 一致才采用, // 防止 sync-changelog 尚未更新时拿到旧版本英文 notes。 async fn fetch_latest_release_notes_en(client: &reqwest::Client, expected_version: &str) -> Result { @@ -245,8 +277,8 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update let github = release.github.as_ref(); let release_notes = non_empty(release.notes_en.as_deref()) .map(ToOwned::to_owned) + .or_else(|| canonical_release_notes(release.notes.as_deref())) .or_else(|| github.and_then(|metadata| non_empty(metadata.body.as_deref())).map(ToOwned::to_owned)) - .or(release.notes) .unwrap_or_default(); let release_name = github .and_then(|metadata| non_empty(metadata.name.as_deref())) @@ -268,6 +300,15 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update } } +fn canonical_release_notes(notes: Option<&str>) -> Option { + let notes = non_empty(notes)?; + // Tauri's generated updater notes are a transport fallback, not the curated release notes. + if notes.starts_with("## What's Changed") || notes == "See the assets below to download and install." { + return None; + } + Some(notes.to_owned()) +} + fn non_empty(value: Option<&str>) -> Option<&str> { value.and_then(|value| { let trimmed = value.trim(); @@ -447,4 +488,38 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings assert_eq!(info.release_notes, "### New Features\n\nReal release notes"); } + + #[test] + fn update_info_ignores_generated_notes_when_curated_notes_are_unavailable() { + let release = TauriRelease { + version: "0.5.3".to_string(), + notes: Some("## What's Changed\n* generated item".to_string()), + jdbc_plugin: None, + github: None, + notes_en: None, + }; + + let info = build_update_info(release, "0.5.2"); + + assert_eq!(info.release_notes, ""); + } + + #[test] + fn update_check_candidates_follow_selected_source() { + assert_eq!( + super::update_check_candidates(crate::DownloadSource::Official), + vec![ + "https://dl.dbxio.com/releases/latest/latest.json", + "https://github.com/t8y2/dbx/releases/latest/download/latest.json", + ] + ); + assert_eq!( + super::update_check_candidates(crate::DownloadSource::Cnb), + vec![ + "https://cnb.cool/dbxio.com/dbx/-/releases/latest/download/latest.json", + "https://dl.dbxio.com/releases/latest/latest.json", + "https://github.com/t8y2/dbx/releases/latest/download/latest.json", + ] + ); + } } diff --git a/crates/dbx-web/src/routes/update.rs b/crates/dbx-web/src/routes/update.rs index 1a3f261201..09e8fb0649 100644 --- a/crates/dbx-web/src/routes/update.rs +++ b/crates/dbx-web/src/routes/update.rs @@ -11,11 +11,13 @@ pub async fn get_version() -> Json { pub struct UpdateCheckParams { #[serde(default)] pub locale: Option, + #[serde(default)] + pub source: Option, } pub async fn check_for_updates(Query(params): Query) -> Result, AppError> { let locale = params.locale.unwrap_or_else(|| "zh-CN".to_string()); - let release = update::fetch_latest_release(&locale).await.map_err(AppError)?; + let release = update::fetch_latest_release(&locale, params.source.unwrap_or_default()).await.map_err(AppError)?; let info = update::build_update_info(release, env!("CARGO_PKG_VERSION")); Ok(Json(serde_json::to_value(info).map_err(|e| AppError(e.to_string()))?)) } diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 2a74e22f82..95aaf8702b 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -132,9 +132,12 @@ fn tag_version(version: &str) -> String { } #[tauri::command] -pub async fn check_for_updates(locale: Option) -> Result { +pub async fn check_for_updates( + locale: Option, + source: Option, +) -> Result { let locale = locale.unwrap_or_else(|| "zh-CN".to_string()); - let release = dbx_core::update::fetch_latest_release(&locale).await?; + let release = dbx_core::update::fetch_latest_release(&locale, source.unwrap_or_default()).await?; let current_version = env!("CARGO_PKG_VERSION"); let mut info = dbx_core::update::build_update_info(release, current_version); info.portable_mode = crate::data_dir::is_portable_mode();