diff --git a/.github/scripts/create-desktop-update-manifest.mjs b/.github/scripts/create-desktop-update-manifest.mjs index dd129e82e11..5b2dc4ecbc5 100755 --- a/.github/scripts/create-desktop-update-manifest.mjs +++ b/.github/scripts/create-desktop-update-manifest.mjs @@ -9,11 +9,19 @@ const platforms = {}; const platformArtifacts = [ [ 'darwin-aarch64', - selectArtifact(assets, /-aarch64-apple-darwin\.app\.tar\.gz$/i, 'darwin-aarch64'), + selectArtifact( + assets, + /-aarch64-apple-darwin\.app\.tar\.gz$/i, + 'darwin-aarch64', + ), ], [ 'darwin-x86_64', - selectArtifact(assets, /-x86_64-apple-darwin\.app\.tar\.gz$/i, 'darwin-x86_64'), + selectArtifact( + assets, + /-x86_64-apple-darwin\.app\.tar\.gz$/i, + 'darwin-x86_64', + ), ], ['windows-x86_64', selectArtifact(assets, /-setup\.exe$/i, 'windows-x86_64')], ['linux-x86_64', selectArtifact(assets, /\.AppImage$/i, 'linux-x86_64')], @@ -25,8 +33,10 @@ for (const [platform, artifact] of platformArtifacts) { throw new Error(`Missing updater signature for ${artifact}`); } platforms[platform] = { - signature: fs.readFileSync(path.join(options.assets, signatureFile), 'utf8').trim(), - url: `https://github.com/${options.repository}/releases/download/${options.tag}/${encodeURIComponent(artifact)}`, + signature: fs + .readFileSync(path.join(options.assets, signatureFile), 'utf8') + .trim(), + url: `${releaseBaseUrl(options)}/${encodeURIComponent(artifact)}`, }; } @@ -47,6 +57,12 @@ function selectArtifact(assets, pattern, platform) { return matches[0]; } +function releaseBaseUrl(options) { + return options['base-url'] + ? options['base-url'].replace(/\/+$/, '') + : `https://github.com/${options.repository}/releases/download/${options.tag}`; +} + function parseArguments(args) { const values = {}; for (let index = 0; index < args.length; index += 2) { diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 54a712d5fb8..95d592f3b30 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -72,6 +72,9 @@ jobs: env: ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}' INPUT_VERSION: '${{ inputs.version }}' + IS_DRAFT: '${{ inputs.draft }}' + IS_DRY_RUN: '${{ inputs.dry_run }}' + IS_PRERELEASE: '${{ inputs.prerelease }}' run: | set -euo pipefail version="${INPUT_VERSION#v}" @@ -79,6 +82,10 @@ jobs: echo "::error::Desktop version must be valid SemVer: $INPUT_VERSION" exit 1 fi + if [ "$IS_DRY_RUN" = 'false' ] && [ "$IS_DRAFT" = 'false' ] && [ "$IS_PRERELEASE" = 'false' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Published stable Desktop versions must use X.Y.Z: $INPUT_VERSION" + exit 1 + fi if [ "$ELECTRON_BRIDGE" = 'true' ]; then core="${version%%[-+]*}" IFS='.' read -r major minor patch <<< "$core" @@ -614,3 +621,21 @@ jobs: echo "Tag: $RELEASE_TAG" echo "Release: $RELEASE_URL" } >> "$GITHUB_STEP_SUMMARY" + + sync-oss: + name: 'Mirror stable Desktop release to Aliyun OSS' + if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false && github.repository == 'QwenLM/qwen-code' }}" + needs: + - 'prepare' + - 'build' + - 'publish' + permissions: + actions: 'read' + contents: 'read' + uses: './.github/workflows/sync-desktop-to-oss.yml' + with: + version: '${{ needs.prepare.outputs.version }}' + source: 'artifact' + secrets: + ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}' + ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}' diff --git a/.github/workflows/sync-desktop-to-oss.yml b/.github/workflows/sync-desktop-to-oss.yml new file mode 100644 index 00000000000..451639587bb --- /dev/null +++ b/.github/workflows/sync-desktop-to-oss.yml @@ -0,0 +1,237 @@ +name: 'Sync Qwen Code Desktop to Aliyun OSS' + +on: + workflow_call: + inputs: + version: + required: true + type: 'string' + source: + required: true + type: 'string' + secrets: + ALIYUN_OSS_ACCESS_KEY_ID: + required: true + ALIYUN_OSS_ACCESS_KEY_SECRET: + required: true + workflow_dispatch: + inputs: + version: + description: 'Stable Desktop version to mirror, for example 0.1.2 or v0.1.2.' + required: true + type: 'string' + source: + description: 'Download the assets from the matching GitHub release.' + required: true + default: 'release' + type: 'choice' + options: + - 'release' + +concurrency: + group: 'sync-desktop-to-oss' + cancel-in-progress: false + +jobs: + sync: + name: 'Mirror Qwen Code Desktop to Aliyun OSS' + if: "${{ github.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main' }}" + runs-on: 'ubuntu-latest' + timeout-minutes: 90 + environment: + name: 'production-release' + permissions: + actions: 'read' + contents: 'read' + steps: + - name: 'Checkout' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + + - name: 'Resolve release' + id: 'release' + env: + INPUT_SOURCE: '${{ inputs.source }}' + INPUT_VERSION: '${{ inputs.version }}' + run: | + set -euo pipefail + version="${INPUT_VERSION#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Desktop OSS mirrors require a stable X.Y.Z version (got '$INPUT_VERSION')." + exit 1 + fi + if [[ "$INPUT_SOURCE" != 'artifact' && "$INPUT_SOURCE" != 'release' ]]; then + echo "::error::Desktop mirror source must be artifact or release (got '$INPUT_SOURCE')." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "source=$INPUT_SOURCE" >> "$GITHUB_OUTPUT" + + - name: 'Download release workflow artifacts' + if: "${{ steps.release.outputs.source == 'artifact' }}" + uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 + with: + pattern: 'desktop-*' + path: 'dist/desktop' + merge-multiple: true + + - name: 'Download GitHub release assets' + if: "${{ steps.release.outputs.source == 'release' }}" + env: + GH_TOKEN: '${{ github.token }}' + VERSION: '${{ steps.release.outputs.version }}' + run: | + set -euo pipefail + metadata="$(gh release view "desktop-v${VERSION}" --json isDraft,isPrerelease)" + if ! jq -e '.isDraft == false and .isPrerelease == false' <<<"$metadata" >/dev/null; then + echo "::error::desktop-v${VERSION} is not a published stable release." + exit 1 + fi + mkdir -p dist/desktop + gh release download "desktop-v${VERSION}" --dir dist/desktop + + - name: 'Verify and prepare mirror assets' + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + VERSION: '${{ steps.release.outputs.version }}' + run: | + set -euo pipefail + cd dist/desktop + if [[ -f SHA256SUMS.txt ]]; then + sha256sum -c SHA256SUMS.txt + fi + for asset in Qwen-Code-Desktop-arm64.dmg Qwen-Code-Desktop-x64.dmg; do + test -f "$asset" || { echo "::error::Missing Desktop installer $asset"; exit 1; } + done + find . -maxdepth 1 -name '*_x64-setup.exe' -print -quit | grep -q . \ + || { echo "::error::Missing Desktop installer matching *_x64-setup.exe"; exit 1; } + find . -maxdepth 1 -name '*.AppImage' -print -quit | grep -q . \ + || { echo "::error::Missing Desktop installer matching *.AppImage"; exit 1; } + find . -maxdepth 1 -name '*.deb' -print -quit | grep -q . \ + || { echo "::error::Missing Desktop installer matching *.deb"; exit 1; } + rm -f desktop-latest.json SHA256SUMS.txt + node ../../.github/scripts/create-desktop-update-manifest.mjs \ + --assets . \ + --repository "$GITHUB_REPOSITORY" \ + --tag "desktop-v${VERSION}" \ + --version "$VERSION" \ + --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/desktop/v${VERSION}" \ + --output desktop-latest.json + sha256sum -- * > SHA256SUMS.txt + + - name: 'Install ossutil' + env: + OSSUTIL_URL: "${{ vars.OSSUTIL_URL || 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' }}" + OSSUTIL_SHA256: "${{ vars.OSSUTIL_SHA256 || 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' }}" + run: | + set -euo pipefail + tmp_dir="$(mktemp -d)" + curl -fsSL --connect-timeout 15 --max-time 300 "$OSSUTIL_URL" -o "$tmp_dir/ossutil.zip" + echo "$OSSUTIL_SHA256 $tmp_dir/ossutil.zip" | sha256sum -c - + unzip -q "$tmp_dir/ossutil.zip" -d "$tmp_dir" + ossutil_path="$(find "$tmp_dir" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)" + if [[ -z "$ossutil_path" ]]; then echo '::error::ossutil binary not found'; exit 1; fi + chmod +x "$ossutil_path" + mkdir -p "$HOME/.local/bin" + install -m 0755 "$ossutil_path" "$HOME/.local/bin/ossutil" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + rm -rf "$tmp_dir" + "$HOME/.local/bin/ossutil" >/dev/null + + - name: 'Configure Aliyun OSS credentials' + env: + ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}' + ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}' + ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}" + run: | + set -euo pipefail + if [[ -z "$ALIYUN_OSS_ACCESS_KEY_ID" || -z "$ALIYUN_OSS_ACCESS_KEY_SECRET" ]]; then + echo '::error::Missing Aliyun OSS credentials in the production-release environment.' + exit 1 + fi + ossutil config -e "$ALIYUN_OSS_ENDPOINT" -i "$ALIYUN_OSS_ACCESS_KEY_ID" -k "$ALIYUN_OSS_ACCESS_KEY_SECRET" -L EN -c "$RUNNER_TEMP/.ossutilconfig" + + - name: 'Upload versioned assets to Aliyun OSS' + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + VERSION: '${{ steps.release.outputs.version }}' + run: | + set -euo pipefail + mapfile -d '' assets < <(find dist/desktop -maxdepth 1 -type f -print0) + node scripts/upload-aliyun-oss-assets.js \ + --bucket "$ALIYUN_OSS_BUCKET" \ + --config "$RUNNER_TEMP/.ossutilconfig" \ + --prefix "desktop/v${VERSION}" \ + "${assets[@]}" + + - name: 'Verify versioned assets on Aliyun OSS' + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + VERSION: '${{ steps.release.outputs.version }}' + run: | + set -euo pipefail + base="${ALIYUN_OSS_PUBLIC_BASE_URL}/desktop/v${VERSION}" + directory="$(mktemp -d)" + trap 'rm -rf "$directory"' EXIT + cp dist/desktop/SHA256SUMS.txt "$directory/SHA256SUMS.txt" + while read -r _ asset; do + curl -fsSL --connect-timeout 15 --max-time 3600 "$base/$asset" -o "$directory/$asset" + done < "$directory/SHA256SUMS.txt" + (cd "$directory" && sha256sum -c SHA256SUMS.txt) + + - name: 'Check whether release matches GitHub stable feed' + id: 'latest' + env: + GH_TOKEN: '${{ github.token }}' + run: | + set -euo pipefail + directory="$(mktemp -d)" + trap 'rm -rf "$directory"' EXIT + gh release download 'desktop-latest' --dir "$directory" --pattern 'desktop-latest.json' + expected="$(jq -r '.version' dist/desktop/desktop-latest.json)" + actual="$(jq -r '.version' "$directory/desktop-latest.json")" + if [ "$actual" = "$expected" ]; then + echo 'matches=true' >> "$GITHUB_OUTPUT" + else + echo 'matches=false' >> "$GITHUB_OUTPUT" + echo "::notice::GitHub stable feed is $actual; mirrored version $expected will not replace the OSS latest feed." + fi + + - name: 'Publish latest manifest to Aliyun OSS' + if: "${{ steps.latest.outputs.matches == 'true' }}" + env: + ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + run: | + node scripts/upload-aliyun-oss-assets.js \ + --bucket "$ALIYUN_OSS_BUCKET" \ + --config "$RUNNER_TEMP/.ossutilconfig" \ + --prefix 'desktop/latest' \ + dist/desktop/desktop-latest.json + + - name: 'Verify latest manifest on Aliyun OSS' + if: "${{ steps.latest.outputs.matches == 'true' }}" + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + run: | + set -euo pipefail + curl -fsSL --connect-timeout 15 --max-time 300 "$ALIYUN_OSS_PUBLIC_BASE_URL/desktop/latest/desktop-latest.json" -o "$RUNNER_TEMP/desktop-latest.json" + cmp dist/desktop/desktop-latest.json "$RUNNER_TEMP/desktop-latest.json" + + - name: 'Publish mirror summary' + env: + ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + IS_LATEST: '${{ steps.latest.outputs.matches }}' + VERSION: '${{ steps.release.outputs.version }}' + run: | + { + echo '## Desktop OSS mirror' + echo + echo "Version: $VERSION" + echo "Assets: ${ALIYUN_OSS_PUBLIC_BASE_URL}/desktop/v${VERSION}/" + if [ "$IS_LATEST" != 'true' ]; then + echo 'Latest feed: unchanged (the mirrored version is not the current GitHub stable release)' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: 'Cleanup Aliyun OSS credentials' + if: '${{ always() }}' + run: 'rm -f "$RUNNER_TEMP/.ossutilconfig"' diff --git a/docs/design/2026-07-31-desktop-web-shell-release.md b/docs/design/2026-07-31-desktop-web-shell-release.md index 1c64f63d861..fb914d00181 100644 --- a/docs/design/2026-07-31-desktop-web-shell-release.md +++ b/docs/design/2026-07-31-desktop-web-shell-release.md @@ -32,7 +32,7 @@ flowchart LR C -->|authenticated loopback URL| D[Existing Web Shell] A -->|retry / choose workspace / logs| B B -->|exit event| A - E[GitHub latest.json + installers] -->|signed updater| B + E[OSS / GitHub update feeds + installers] -->|signed updater| B ``` ### 组件职责 @@ -122,13 +122,13 @@ flowchart LR ## 更新模型 -Tauri updater 使用签名更新产物和固定公开 key。应用启动后后台检查一次更新: +Tauri updater 使用签名更新产物和固定公开 key。稳定发布的安装包和 updater 产物同时保存在 GitHub Releases 与 Aliyun OSS;应用优先检查 OSS 的小型更新清单,并在请求失败或超时时回退 GitHub。两个清单分别指向同一版本在各自源中的签名产物。应用启动后后台检查一次更新: - 无更新:不打扰用户。 - 检查失败:写日志,不阻塞启动。 - 有更新:bootstrap/Web Shell 上方显示原生确认对话框;用户确认后下载并安装,然后重启。 -发布 CI 使用 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 生成 updater signatures。`latest.json` 指向同一 GitHub Release 的平台更新包。只有非 draft、非 prerelease 发布会更新固定的 `desktop-latest` feed release。 +发布 CI 使用 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 生成 updater signatures。只有非 draft、非 prerelease 发布会更新 GitHub 的 `desktop-latest` feed,并在校验版本化 OSS 产物后更新 OSS feed。GitHub 始终保留为权威发布源和回退源。 ## 平台发布矩阵 @@ -149,7 +149,7 @@ Windows WebView2 使用 download bootstrapper;系统离线且缺失 WebView2 5. 构建安装包和 updater artifacts。 6. 平台 runner 安装并启动 packaged app,等待 daemon/Web Shell ready 证据。 7. 上传产物;发布 job 生成 `latest.json` 和 `SHA256SUMS.txt`。 -8. 非 draft stable release 更新 `desktop-latest` feed。 +8. 非 draft stable release 更新 GitHub `desktop-latest` feed,将同一批产物同步并校验到 OSS,再更新 OSS feed。 缺失签名密钥时只允许 `dry_run=true`,公开发布必须 fail closed。 diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index 86be8f9967b..4951f0dd604 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -42,6 +42,7 @@ try { testLegacyApplicationIdentity(); testElectronBridgeWorkflow(); testDesktopReleaseSigningWorkflow(); + testUpdaterMirrorConfiguration(); testResolveLogRoot(); testSliceNewLog(); testUpdateManifest(path.join(root, 'manifest')); @@ -198,6 +199,26 @@ function testDesktopReleaseSigningWorkflow() { ); } +function testUpdaterMirrorConfiguration() { + assert.deepEqual(tauriConfig.plugins?.updater?.endpoints, [ + 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/desktop/latest/desktop-latest.json', + 'https://github.com/QwenLM/qwen-code/releases/download/desktop-latest/desktop-latest.json', + ]); + const main = fs.readFileSync( + path.join(packageDir, 'src-tauri', 'src', 'main.rs'), + 'utf8', + ); + assert.match( + main, + /const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs\(3\);/, + ); + assert.match( + main, + /app\.updater_builder\(\)\s*\.timeout\(UPDATE_CHECK_TIMEOUT\)/, + ); + assert.equal((main.match(/check_for_update\(&app\)/g) ?? []).length, 2); +} + function testBootstrapBridgeConfiguration() { assert.equal( tauriConfig.app?.withGlobalTauri, @@ -386,6 +407,34 @@ function testUpdateManifest(directory) { ); } + execFileSync(process.execPath, [ + manifestScript, + '--assets', + assets, + '--repository', + 'QwenLM/qwen-code', + '--tag', + 'desktop-v0.1.0', + '--version', + '0.1.0', + '--base-url', + 'https://mirror.example/desktop/v0.1.0/', + '--output', + output, + ]); + const mirrorManifest = JSON.parse(fs.readFileSync(output, 'utf8')); + for (const [platform, artifact] of [ + ['darwin-aarch64', artifacts[0]], + ['darwin-x86_64', artifacts[1]], + ['windows-x86_64', artifacts[2]], + ['linux-x86_64', artifacts[3]], + ]) { + assert.equal( + mirrorManifest.platforms[platform].url, + `https://mirror.example/desktop/v0.1.0/${encodeURIComponent(artifact)}`, + ); + } + fs.rmSync(path.join(assets, `${artifacts[3]}.sig`)); const failure = spawnSync( process.execPath, diff --git a/packages/desktop-shell/src-tauri/src/main.rs b/packages/desktop-shell/src-tauri/src/main.rs index 2b66d804ce2..26d43d82b8f 100755 --- a/packages/desktop-shell/src-tauri/src/main.rs +++ b/packages/desktop-shell/src-tauri/src/main.rs @@ -14,6 +14,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use tauri::menu::{Menu, MenuItem, MenuItemBuilder, SubmenuBuilder}; use tauri::webview::{DownloadEvent, NewWindowResponse, WebviewWindowBuilder}; use tauri::{ @@ -21,7 +22,7 @@ use tauri::{ WindowEvent, }; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; -use tauri_plugin_updater::UpdaterExt; +use tauri_plugin_updater::{Update, UpdaterExt}; use url::Url; #[cfg(target_os = "windows")] @@ -37,6 +38,7 @@ static FULLSCREEN_HIDE_GENERATION: AtomicU64 = AtomicU64::new(0); // packages/desktop/packages/shared/src/config/storage.ts: ~/Documents/Qwen, // relocatable through QWEN_DEFAULT_WORKSPACE_DIR (see default_workspace). const DEFAULT_WORKSPACE_DIRECTORY: &str = "Qwen"; +const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -431,12 +433,8 @@ fn open_logs( #[tauri::command] async fn install_update(webview: WebviewWindow, app: AppHandle) -> Result<(), String> { require_bootstrap_origin(&webview)?; - let update = app - .updater() - .map_err(|error| format!("Failed to initialize updater: {error}"))? - .check() - .await - .map_err(|error| format!("Failed to check for updates: {error}"))? + let update = check_for_update(&app) + .await? .ok_or_else(|| "No desktop update is available.".to_string())?; let version = update.version.clone(); let confirmed = tauri::async_runtime::spawn_blocking({ @@ -825,11 +823,7 @@ fn check_updates_silently(app: AppHandle) { return; } tauri::async_runtime::spawn(async move { - let updater = match app.updater() { - Ok(updater) => updater, - Err(_) => return, - }; - let Ok(Some(update)) = updater.check().await else { + let Ok(Some(update)) = check_for_update(&app).await else { return; }; let _ = app.emit("update-available", update.version.clone()); @@ -876,6 +870,16 @@ fn check_updates_silently(app: AppHandle) { }); } +async fn check_for_update(app: &AppHandle) -> Result, String> { + app.updater_builder() + .timeout(UPDATE_CHECK_TIMEOUT) + .build() + .map_err(|error| format!("Failed to initialize updater: {error}"))? + .check() + .await + .map_err(|error| format!("Failed to check for updates: {error}")) +} + fn is_safe_external_url(url: &Url) -> bool { matches!(url.scheme(), "https" | "http" | "mailto") } diff --git a/packages/desktop-shell/src-tauri/tauri.conf.json b/packages/desktop-shell/src-tauri/tauri.conf.json index da8f874953f..b8d9e1d1224 100644 --- a/packages/desktop-shell/src-tauri/tauri.conf.json +++ b/packages/desktop-shell/src-tauri/tauri.conf.json @@ -59,6 +59,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkzMTlFNDMyRTVFNkUyNUMKUldSYzR1YmxNdVFaa3pJWjBPMERVVUw0akFvU3ZxS0pNMTFrcUhCbDlUaEVuRkVEVEcxaDBHNEcK", "endpoints": [ + "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/desktop/latest/desktop-latest.json", "https://github.com/QwenLM/qwen-code/releases/download/desktop-latest/desktop-latest.json" ] } diff --git a/scripts/tests/desktop-oss-workflow.test.js b/scripts/tests/desktop-oss-workflow.test.js new file mode 100644 index 00000000000..63bf6e96502 --- /dev/null +++ b/scripts/tests/desktop-oss-workflow.test.js @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js'; + +const releaseWorkflow = readFileSync( + '.github/workflows/desktop-release.yml', + 'utf8', +); +const syncWorkflow = readFileSync( + '.github/workflows/sync-desktop-to-oss.yml', + 'utf8', +); +const tauriConfig = JSON.parse( + readFileSync('packages/desktop-shell/src-tauri/tauri.conf.json', 'utf8'), +); + +describe('Desktop OSS mirror workflow', () => { + it('mirrors only published stable Desktop releases', () => { + expect(syncWorkflow).not.toContain('pull_request:'); + const prepare = getWorkflowStep( + getWorkflowJob(releaseWorkflow, 'prepare'), + 'Resolve version', + ); + expect(prepare).toContain("IS_DRAFT: '${{ inputs.draft }}'"); + expect(prepare).toContain("IS_DRY_RUN: '${{ inputs.dry_run }}'"); + expect(prepare).toContain("IS_PRERELEASE: '${{ inputs.prerelease }}'"); + expect(prepare).toContain( + 'Published stable Desktop versions must use X.Y.Z', + ); + + const syncOss = getWorkflowJob(releaseWorkflow, 'sync-oss'); + expect(syncOss).toContain( + "if: \"${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false && github.repository == 'QwenLM/qwen-code' }}\"", + ); + expect(syncOss).toContain("- 'publish'"); + expect(syncOss).toContain("source: 'artifact'"); + expect(syncOss).not.toContain('secrets: inherit'); + }); + + it('passes only the OSS credentials into the reusable workflow', () => { + expect(releaseWorkflow).toContain("permissions:\n contents: 'read'"); + const syncOss = getWorkflowJob(releaseWorkflow, 'sync-oss'); + expect(syncOss).toContain( + "permissions:\n actions: 'read'\n contents: 'read'", + ); + for (const secret of [ + 'ALIYUN_OSS_ACCESS_KEY_ID', + 'ALIYUN_OSS_ACCESS_KEY_SECRET', + ]) { + expect(syncWorkflow).toContain(`${secret}:\n required: true`); + expect(syncOss).toContain(`${secret}: '\${{ secrets.${secret} }}'`); + } + }); + + it('publishes verified versioned assets before advancing the OSS feed', () => { + const sync = getWorkflowJob(syncWorkflow, 'sync'); + const prepare = getWorkflowStep(sync, 'Verify and prepare mirror assets'); + expect(prepare).toContain( + '--base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/desktop/v${VERSION}"', + ); + expect(prepare).toContain('sha256sum -- * > SHA256SUMS.txt'); + + const upload = getWorkflowStep( + sync, + 'Upload versioned assets to Aliyun OSS', + ); + expect(upload).toContain('--prefix "desktop/v${VERSION}"'); + + const latest = getWorkflowStep( + sync, + 'Publish latest manifest to Aliyun OSS', + ); + expect(latest).toContain("--prefix 'desktop/latest'"); + expect(latest).toContain('dist/desktop/desktop-latest.json'); + expect(latest).not.toContain('.dmg'); + + const verifyIndex = sync.indexOf( + "name: 'Verify versioned assets on Aliyun OSS'", + ); + expect(verifyIndex).toBeGreaterThan(0); + expect(verifyIndex).toBeLessThan( + sync.indexOf("name: 'Publish latest manifest to Aliyun OSS'"), + ); + expect( + getWorkflowStep(sync, 'Verify versioned assets on Aliyun OSS'), + ).toContain('sha256sum -c SHA256SUMS.txt'); + expect( + getWorkflowStep(sync, 'Verify latest manifest on Aliyun OSS'), + ).toContain('cmp '); + }); + + it('advances the OSS feed only for the current GitHub stable version', () => { + const sync = getWorkflowJob(syncWorkflow, 'sync'); + const check = getWorkflowStep( + sync, + 'Check whether release matches GitHub stable feed', + ); + expect(check).toContain("gh release download 'desktop-latest'"); + expect(check).toContain('echo \'matches=true\' >> "$GITHUB_OUTPUT"'); + expect(check).toContain('echo \'matches=false\' >> "$GITHUB_OUTPUT"'); + expect(check).toContain( + 'expected="$(jq -r \'.version\' dist/desktop/desktop-latest.json)"', + ); + expect(check).toContain( + 'actual="$(jq -r \'.version\' "$directory/desktop-latest.json")"', + ); + expect( + getWorkflowStep(sync, 'Publish latest manifest to Aliyun OSS'), + ).toContain('if: "${{ steps.latest.outputs.matches == \'true\' }}"'); + expect( + getWorkflowStep(sync, 'Verify latest manifest on Aliyun OSS'), + ).toContain('if: "${{ steps.latest.outputs.matches == \'true\' }}"'); + }); + + it('sync workflow validates stable-only releases in the reusable job', () => { + expect(syncWorkflow).not.toContain('pull_request:'); + const sync = getWorkflowJob(syncWorkflow, 'sync'); + const resolve = getWorkflowStep(sync, 'Resolve release'); + expect(resolve).toContain('^[0-9]+\\.[0-9]+\\.[0-9]+$'); + expect(resolve).toContain("!= 'artifact'"); + expect(resolve).toContain("!= 'release'"); + expect(getWorkflowStep(sync, 'Download GitHub release assets')).toContain( + '.isDraft == false and .isPrerelease == false', + ); + }); + + it('keeps the workflow default aligned with the shipped updater endpoint', () => { + const firstEndpoint = tauriConfig.plugins.updater.endpoints[0]; + expect(syncWorkflow).toContain(new URL(firstEndpoint).origin); + }); +});