Merge pull request #86 from router-for-me/dev

feat(updater): keep publishing legacy Windows update packages
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-03 16:05:24 +08:00
commit 3d72a80af3
8 files changed
+125 -56

No files matched your search

+18 -22
View File
@@ -190,8 +190,8 @@ jobs:
throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`);
}
NODE
- name: Prepare v0.2.10 legacy updater bridge
if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10'
- name: Prepare Windows legacy updater payload
if: matrix.os == 'windows'
shell: pwsh
run: |
$updateDir = "release/update-${{ matrix.arch }}"
@@ -201,11 +201,11 @@ jobs:
$marker = Get-Content -Raw -LiteralPath "$updateDir/portable-app.json" | ConvertFrom-Json
if ($marker.schemaVersion -ne 1 -or
$marker.application -ne "EasyCLIProxyAPI" -or
$marker.version -ne "0.2.10" -or
$marker.version -ne "${{ steps.package.outputs.version }}" -or
$marker.platform -ne "windows" -or
$marker.arch -ne "${{ matrix.arch }}" -or
$marker.autoUpdate -ne $true) {
throw "v0.2.10 legacy updater bridge does not match the Windows target"
throw "Legacy updater payload does not match the Windows target"
}
- name: Build signed macOS DMG
if: matrix.os == 'darwin'
@@ -341,8 +341,6 @@ jobs:
- name: Archive Windows release
if: matrix.os == 'windows'
shell: pwsh
env:
LEGACY_UPDATE_BRIDGE: ${{ steps.package.outputs.version == '0.2.10' }}
run: |
Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip"
Add-Type -AssemblyName System.IO.Compression.FileSystem
@@ -360,20 +358,18 @@ jobs:
} finally {
$zip.Dispose()
}
if ($env:LEGACY_UPDATE_BRIDGE -eq "true") {
$updateArchive = "EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip"
Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive
$updateZip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive))
try {
$updateEntries = @($updateZip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
if ($updateEntries.Count -ne 2 -or
$updateEntries -notcontains "EasyCLIProxyAPI.exe" -or
$updateEntries -notcontains "portable-app.json") {
throw "v0.2.10 legacy updater bridge contains unexpected entries: $($updateEntries -join ', ')"
}
} finally {
$updateZip.Dispose()
$updateArchive = "EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip"
Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive
$updateZip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive))
try {
$updateEntries = @($updateZip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
if ($updateEntries.Count -ne 2 -or
$updateEntries -notcontains "EasyCLIProxyAPI.exe" -or
$updateEntries -notcontains "portable-app.json") {
throw "Legacy updater ZIP contains unexpected entries: $($updateEntries -join ', ')"
}
} finally {
$updateZip.Dispose()
}
- uses: actions/upload-artifact@v7
with:
@@ -382,10 +378,10 @@ jobs:
if-no-files-found: error
retention-days: 14
- uses: actions/upload-artifact@v7
if: matrix.os == 'windows' && steps.package.outputs.version == '0.2.10'
if: matrix.os == 'windows'
with:
name: legacy-updater-bridge-windows-${{ matrix.arch }}
path: EasyCLIProxyAPI-update-v0.2.10-Windows-${{ matrix.arch }}.zip
name: portable-updater-windows-${{ matrix.arch }}
path: EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip
if-no-files-found: error
retention-days: 14
publish:
+1 -1
View File
@@ -119,7 +119,7 @@ OpenAI、Claude、Gemini、およびその他の互換形式の間で変換で
## アップグレード
v0.2.10 は一度限りの移行用リリースです。完全版 Windows ZIP と旧クライアント互換の `update` ZIP を同時に公開し、v0.2.6~v0.2.9 からアプリ内で v0.2.10 へ更新できます。v0.2.11 以降は完全版 ZIP のみを公開し、v0.2.10 へ移行済みのクライアントは途中のバージョンを個別に導入せず直接アップグレードできます。
すべての Windows リリースで、完全版 ZIP と旧クライアント互換の `update` ZIP を同時に公開します。まだ移行できていない旧クライアントでもアプリ内更新を利用でき、新しいクライアントでは内蔵 core も更新できる完全版パッケージを使用します。
v0.2.5 以前を使用している場合は、一度だけ手動で移行してください。EasyCLIProxyAPI を終了し、使用中のアーキテクチャに対応する最新版の完全版 Windows ZIP をダウンロードして、その最上位ディレクトリの内容を既存のインストール先へコピーし、同名ファイルを上書きします。既存のディレクトリを先に削除しないでください。`config.toml`、`oauth`、`cpa-core/config.yaml` などのユーザーデータは保持されます。新しいバージョンを起動した後は、以降のリリースでアプリ内自動更新を利用できます。
+1 -1
View File
@@ -121,7 +121,7 @@ previous configuration, and launch an available desktop or CLI entry point.
## Upgrading
v0.2.10 is a one-time bridge release. It publishes both the complete Windows ZIP and the legacy `update` ZIP so v0.2.6–v0.2.9 can upgrade to v0.2.10 in the app. Starting with v0.2.11, only the complete ZIP is published; clients already on v0.2.10 can consume it and jump directly across intermediate releases.
Every Windows release publishes both the complete ZIP and the legacy `update` ZIP. This keeps in-app updates available to older clients that have not migrated yet, while newer clients use the complete package so the bundled core can be updated too.
If you are running v0.2.5 or earlier, perform one manual migration: exit EasyCLIProxyAPI, download the latest complete Windows ZIP for your architecture, then copy the contents of its top-level directory over the existing installation directory. Do not delete the existing directory first; user data such as `config.toml`, `oauth`, and `cpa-core/config.yaml` will remain in place. After launching the new version, later releases can use in-app automatic updates.
+1 -1
View File
@@ -116,7 +116,7 @@ API 接入页面按照协议或 Provider 管理上游 API 凭证和服务地址
## 升级
v0.2.10 是一次性桥接版本:它会同时发布完整 Windows ZIP 和兼容旧客户端的 `update` ZIP,使 v0.2.6–v0.2.9 可以通过应用内更新升级到 v0.2.10。v0.2.11 起只发布完整 ZIP;已升级到 v0.2.10 的客户端可以识别完整包并跨过多个中间版本直接升级。
每个 Windows 版本都会同时发布完整 ZIP 和兼容旧客户端的 `update` ZIP。这样尚未及时迁移的旧客户端仍可使用应用内更新;新版客户端则使用完整包,同时更新内置 core。
如果当前版本是 v0.2.5 或更早版本,请执行一次手动迁移:退出 EasyCLIProxyAPI,下载最新版对应架构的完整 Windows ZIP,将 ZIP 顶层目录内的内容复制到现有安装目录并覆盖同名文件。不要先删除现有安装目录;`config.toml`、`oauth` 和 `cpa-core/config.yaml` 等用户数据会被保留。启动新版后,后续版本即可继续使用应用内自动升级。
@@ -4,14 +4,13 @@ export type PortableUpdateManifestAsset = {
sizeBytes: number;
};
export const LEGACY_UPDATE_BRIDGE_VERSION: string;
export type PortableUpdateManifest = {
schemaVersion: number;
version: string;
publishedAt: string;
releaseUrl: string;
assets: Record<string, PortableUpdateManifestAsset>;
fullAssets: Record<string, PortableUpdateManifestAsset>;
};
export function generatePortableUpdateManifest(options: {
+16 -15
View File
@@ -3,8 +3,6 @@ import { readFile, stat, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const LEGACY_UPDATE_BRIDGE_VERSION = '0.2.10';
export async function generatePortableUpdateManifest({
directory,
output,
@@ -31,21 +29,23 @@ export async function generatePortableUpdateManifest({
}
const assets = {};
const useLegacyBridgeAssets = version === LEGACY_UPDATE_BRIDGE_VERSION;
const fullAssets = {};
for (const arch of ['amd64', 'aarch64']) {
const filename = useLegacyBridgeAssets
? `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`
: `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`;
const path = join(resolvedDirectory, filename);
const [contents, metadata] = await Promise.all([readFile(path), stat(path)]);
if (!metadata.isFile() || metadata.size === 0) {
throw new Error(`Portable release asset is empty or not a file: ${filename}`);
for (const [collection, filename] of [
[assets, `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`],
[fullAssets, `EasyCLIProxyAPI-${tag}-Windows-${arch}.zip`],
]) {
const path = join(resolvedDirectory, filename);
const [contents, metadata] = await Promise.all([readFile(path), stat(path)]);
if (!metadata.isFile() || metadata.size === 0) {
throw new Error(`Portable release asset is empty or not a file: ${filename}`);
}
collection[`windows-${arch}`] = {
url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`,
sha256: createHash('sha256').update(contents).digest('hex'),
sizeBytes: metadata.size,
};
}
assets[`windows-${arch}`] = {
url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`,
sha256: createHash('sha256').update(contents).digest('hex'),
sizeBytes: metadata.size,
};
}
const manifest = {
@@ -54,6 +54,7 @@ export async function generatePortableUpdateManifest({
publishedAt,
releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`,
assets,
fullAssets,
};
await writeFile(resolvedOutput, `${JSON.stringify(manifest, null, 2)}\n`);
+59 -3
View File
@@ -353,6 +353,8 @@ struct PortableUpdateManifest {
published_at: String,
release_url: String,
assets: std::collections::HashMap<String, PortableUpdateAsset>,
#[serde(default)]
full_assets: Option<std::collections::HashMap<String, PortableUpdateAsset>>,
}
#[derive(Clone, Debug, Deserialize)]
@@ -10045,9 +10047,8 @@ async fn check_app_update(
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
let update_available = is_app_update_available(&current_version, &latest_version)?;
let target = portable_update_target();
let asset = target
.and_then(|(key, _)| manifest.assets.get(key))
.cloned();
let asset_catalog = manifest.full_assets.as_ref().unwrap_or(&manifest.assets);
let asset = target.and_then(|(key, _)| asset_catalog.get(key)).cloned();
let portable_support = target
.map(|(_, arch)| validate_local_portable_app_manifest(arch))
.transpose()?;
@@ -10179,6 +10180,31 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul
return Err(format!("软件更新资产名称与 {key} 不匹配"));
}
}
if let Some(full_assets) = &manifest.full_assets {
if full_assets.len() != 2 {
return Err(
"Full update asset catalog must contain both Windows architectures".to_string(),
);
}
for (key, arch) in [("windows-amd64", "amd64"), ("windows-aarch64", "aarch64")] {
let asset = full_assets
.get(key)
.ok_or_else(|| format!("Full update asset catalog is missing {key}"))?;
validate_portable_update_asset(asset)?;
let package_name = format!(
"EasyCLIProxyAPI-v{}-Windows-{arch}.zip",
manifest.version.trim().trim_start_matches('v')
);
let expected_url = format!(
"{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}",
manifest.version.trim().trim_start_matches('v'),
package_name
);
if asset.url != expected_url {
return Err(format!("Full update asset name does not match {key}"));
}
}
}
Ok(())
}
@@ -22406,6 +22432,7 @@ custom_option = "keep-original"
]
.into_iter()
.collect(),
full_assets: None,
}
}
@@ -22425,6 +22452,35 @@ custom_option = "keep-original"
);
assert!(validate_portable_update_manifest(&legacy_manifest).is_ok());
let mut dual_manifest = portable_update_test_manifest("1.2.3");
dual_manifest.assets = [
(
"windows-amd64".to_string(),
portable_update_test_legacy_asset("1.2.3", "amd64"),
),
(
"windows-aarch64".to_string(),
portable_update_test_legacy_asset("1.2.3", "aarch64"),
),
]
.into_iter()
.collect();
dual_manifest.full_assets = Some(
[
(
"windows-amd64".to_string(),
portable_update_test_asset("1.2.3", "amd64"),
),
(
"windows-aarch64".to_string(),
portable_update_test_asset("1.2.3", "aarch64"),
),
]
.into_iter()
.collect(),
);
assert!(validate_portable_update_manifest(&dual_manifest).is_ok());
let mut missing_arch = portable_update_test_manifest("1.2.3");
missing_arch.assets.remove("windows-aarch64");
assert!(validate_portable_update_manifest(&missing_arch).is_err());
+28 -11
View File
@@ -5,7 +5,6 @@ import { join } from 'node:path';
import { describe, expect, test } from 'bun:test';
import {
generatePortableUpdateManifest,
LEGACY_UPDATE_BRIDGE_VERSION,
} from '../scripts/generate-portable-update-manifest.mjs';
describe('Windows 便携更新清单', () => {
@@ -17,6 +16,10 @@ describe('Windows 便携更新清单', () => {
aarch64: Buffer.from('aarch64 full portable package'),
};
for (const [arch, contents] of Object.entries(payloads)) {
await writeFile(
join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`),
Buffer.from(`${arch} legacy update package`),
);
await writeFile(
join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`),
contents,
@@ -37,10 +40,14 @@ describe('Windows 便携更新清单', () => {
for (const arch of ['amd64', 'aarch64'] as const) {
const asset = manifest.assets[`windows-${arch}`];
expect(asset.url).toBe(
`https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`,
);
const fullAsset = manifest.fullAssets[`windows-${arch}`];
expect(fullAsset.url).toBe(
`https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`,
);
expect(asset.sizeBytes).toBe(payloads[arch].byteLength);
expect(asset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex'));
expect(fullAsset.sizeBytes).toBe(payloads[arch].byteLength);
expect(fullAsset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex'));
}
} finally {
await rm(root, { recursive: true, force: true });
@@ -51,7 +58,7 @@ describe('Windows 便携更新清单', () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-missing-'));
try {
await writeFile(
join(root, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'),
join(root, 'EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip'),
'amd64',
);
await expect(generatePortableUpdateManifest({
@@ -65,28 +72,38 @@ describe('Windows 便携更新清单', () => {
}
});
test('v0.2.10 使用一次性旧版桥接资产,后续版本恢复完整包', async () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-bridge-'));
test('所有版本同时保留旧版更新包和完整包', async () => {
const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-dual-'));
try {
for (const arch of ['amd64', 'aarch64']) {
await writeFile(
join(root, `EasyCLIProxyAPI-update-v${LEGACY_UPDATE_BRIDGE_VERSION}-Windows-${arch}.zip`),
`legacy bridge ${arch}`,
join(root, `EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`),
`legacy update ${arch}`,
);
await writeFile(
join(root, `EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`),
`full package ${arch}`,
);
}
const manifest = await generatePortableUpdateManifest({
directory: root,
output: join(root, 'portable-update-windows.json'),
repository: 'router-for-me/EasyCLIProxyAPI',
tag: `v${LEGACY_UPDATE_BRIDGE_VERSION}`,
tag: 'v1.2.3',
publishedAt: '2026-08-02T00:00:00.000Z',
});
expect(manifest.assets['windows-amd64'].url).toEndWith(
'/EasyCLIProxyAPI-update-v0.2.10-Windows-amd64.zip',
'/EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip',
);
expect(manifest.assets['windows-aarch64'].url).toEndWith(
'/EasyCLIProxyAPI-update-v0.2.10-Windows-aarch64.zip',
'/EasyCLIProxyAPI-update-v1.2.3-Windows-aarch64.zip',
);
expect(manifest.fullAssets['windows-amd64'].url).toEndWith(
'/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip',
);
expect(manifest.fullAssets['windows-aarch64'].url).toEndWith(
'/EasyCLIProxyAPI-v1.2.3-Windows-aarch64.zip',
);
} finally {
await rm(root, { recursive: true, force: true });