From 06f024abde203e06e6dceaf90d8a12c824050361 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 5 Aug 2026 16:23:17 +0800 Subject: [PATCH] feat(release): add GitCode fallback mirror --- .github/workflows/release.yml | 16 +- scripts/generate-portable-update-manifest.mjs | 23 +- scripts/publish-gitcode-release.mjs | 144 ++++++++++ src-tauri/src/main.rs | 253 ++++++++++++++++-- tests/gitcodeRelease.test.ts | 52 ++++ tests/portableUpdateManifest.test.ts | 7 + 6 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 scripts/publish-gitcode-release.mjs create mode 100644 tests/gitcodeRelease.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4e9362..accc965 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,6 +166,7 @@ jobs: sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - name: Build GUI env: + GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} RUSTFLAGS: -D warnings run: | bun install --frozen-lockfile @@ -390,7 +391,7 @@ jobs: if-no-files-found: error retention-days: 14 publish: - name: Publish GitHub release + name: Publish releases if: >- (github.event_name == 'release' && github.event.release.prerelease == true) || startsWith(github.ref, 'refs/tags/') @@ -399,7 +400,7 @@ jobs: - version - build runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: @@ -417,6 +418,7 @@ jobs: --directory artifacts --output artifacts/portable-update-windows.json --repository "${{ github.repository }}" + --gitcode-repository "${{ vars.GITCODE_REPOSITORY }}" --tag "$RELEASE_TAG" - name: Publish release shell: bash @@ -459,3 +461,13 @@ jobs: else gh release create "$RELEASE_TAG" "${release_args[@]}" artifacts/* fi + - name: Publish GitCode mirror + if: ${{ vars.GITCODE_REPOSITORY != '' }} + env: + ARTIFACT_DIRECTORY: artifacts + GITCODE_ACCESS_TOKEN: ${{ secrets.GITCODE_ACCESS_TOKEN }} + GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }} + GITCODE_TARGET_COMMITISH: ${{ vars.GITCODE_TARGET_COMMITISH || 'main' }} + PRERELEASE: ${{ github.event_name == 'release' && github.event.release.prerelease || inputs.prerelease }} + RELEASE_TAG: ${{ needs.version.outputs.tag }} + run: node scripts/publish-gitcode-release.mjs diff --git a/scripts/generate-portable-update-manifest.mjs b/scripts/generate-portable-update-manifest.mjs index b3a205d..1091f7c 100644 --- a/scripts/generate-portable-update-manifest.mjs +++ b/scripts/generate-portable-update-manifest.mjs @@ -7,12 +7,14 @@ export async function generatePortableUpdateManifest({ directory, output, repository, + gitcodeRepository, tag: rawTag, publishedAt = new Date().toISOString(), }) { const resolvedDirectory = resolve(directory ?? 'artifacts'); const resolvedOutput = resolve(output ?? join(resolvedDirectory, 'portable-update-windows.json')); const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI'; + const resolvedGitcodeRepository = String(gitcodeRepository ?? '').trim(); const normalizedRawTag = String(rawTag ?? '').trim(); const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`; const version = tag.slice(1); @@ -24,6 +26,10 @@ export async function generatePortableUpdateManifest({ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) { throw new Error(`Invalid GitHub repository: ${resolvedRepository}`); } + if (resolvedGitcodeRepository + && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedGitcodeRepository)) { + throw new Error(`Invalid GitCode repository: ${resolvedGitcodeRepository}`); + } if (Number.isNaN(Date.parse(publishedAt))) { throw new Error(`Invalid publishedAt: ${publishedAt}`); } @@ -40,11 +46,19 @@ export async function generatePortableUpdateManifest({ if (!metadata.isFile() || metadata.size === 0) { throw new Error(`Portable release asset is empty or not a file: ${filename}`); } - collection[`windows-${arch}`] = { + const asset = { url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`, sha256: createHash('sha256').update(contents).digest('hex'), sizeBytes: metadata.size, }; + if (resolvedGitcodeRepository) { + asset.fallbackUrls = [gitcodeReleaseAttachmentUrl( + resolvedGitcodeRepository, + tag, + filename, + )]; + } + collection[`windows-${arch}`] = asset; } } @@ -72,11 +86,18 @@ async function main() { directory, output, repository: args.get('--repository'), + gitcodeRepository: args.get('--gitcode-repository'), tag: args.get('--tag'), }); console.log(`Generated ${basename(output)} for v${manifest.version}`); } +export function gitcodeReleaseAttachmentUrl(repository, tag, filename) { + const [owner, repo] = repository.split('/'); + return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}` + + `/releases/${tag}/attach_files/${filename}/download`; +} + const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; if (import.meta.url === entryPoint) { await main(); diff --git a/scripts/publish-gitcode-release.mjs b/scripts/publish-gitcode-release.mjs new file mode 100644 index 0000000..6c238b3 --- /dev/null +++ b/scripts/publish-gitcode-release.mjs @@ -0,0 +1,144 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const API_BASE = 'https://api.gitcode.com/api/v5'; + +function parseRepository(value) { + const repository = String(value ?? '').trim(); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error(`Invalid GitCode repository: ${repository}`); + } + return repository.split('/'); +} + +function authenticatedUrl(path, token, query = {}) { + const url = new URL(`${API_BASE}${path}`); + url.searchParams.set('access_token', token); + for (const [name, value] of Object.entries(query)) { + url.searchParams.set(name, value); + } + return url; +} + +async function apiRequest(path, { + token, + method = 'GET', + body, + query, + expected = [200], + fetchImpl = fetch, +}) { + let response; + try { + response = await fetchImpl(authenticatedUrl(path, token, query), { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + throw new Error(`GitCode API request failed for ${path}: ${error instanceof Error ? error.message : error}`); + } + if (!expected.includes(response.status)) { + const details = (await response.text()).slice(0, 500); + throw new Error(`GitCode API ${method} ${path} failed: HTTP ${response.status}: ${details}`); + } + return response.status === 204 || response.status === 404 ? null : response.json(); +} + +export async function publishGitcodeRelease({ + directory, + repository, + token, + tag, + targetCommitish = 'main', + prerelease = false, + fetchImpl = fetch, +}) { + const [owner, repo] = parseRepository(repository); + if (!token) throw new Error('GITCODE_ACCESS_TOKEN is required'); + if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(tag)) { + throw new Error(`Invalid release tag: ${tag}`); + } + + const encodedOwner = encodeURIComponent(owner); + const encodedRepo = encodeURIComponent(repo); + const encodedTag = encodeURIComponent(tag); + const releaseByTagPath = `/repos/${encodedOwner}/${encodedRepo}/releases/tags/${encodedTag}`; + let release; + const existing = await apiRequest(releaseByTagPath, { + token, + expected: [200, 404], + fetchImpl, + }); + if (existing?.tag_name === tag) { + release = existing; + } else { + release = await apiRequest(`/repos/${encodedOwner}/${encodedRepo}/releases`, { + token, + method: 'POST', + body: { + tag_name: tag, + name: `EasyCLIProxyAPI ${tag}`, + body: 'Mirror of the GitHub release for users who cannot access GitHub.', + target_commitish: targetCommitish, + release_status: prerelease ? 'pre' : 'latest', + }, + fetchImpl, + }); + } + + const existingAssets = new Set((release.assets ?? []).map((asset) => asset.name)); + const entries = (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .sort((left, right) => left.name.localeCompare(right.name)); + if (entries.length === 0) throw new Error(`No release artifacts found in ${directory}`); + + for (const entry of entries) { + if (existingAssets.has(entry.name)) { + console.log(`GitCode asset already exists, skipping: ${entry.name}`); + continue; + } + const upload = await apiRequest( + `/repos/${encodedOwner}/${encodedRepo}/releases/${encodedTag}/upload_url`, + { token, query: { file_name: entry.name }, fetchImpl }, + ); + const descriptor = upload?.data ?? upload; + const uploadUrl = descriptor?.upload_url ?? descriptor?.url; + if (typeof uploadUrl !== 'string' || !uploadUrl.startsWith('https://')) { + throw new Error(`GitCode returned an invalid upload URL for ${entry.name}`); + } + const rawHeaders = descriptor?.headers ?? descriptor?.upload_headers ?? descriptor?.header; + const headers = Array.isArray(rawHeaders) + ? Object.fromEntries(rawHeaders.map(({ key, name, value }) => [key ?? name, value])) + : rawHeaders && typeof rawHeaders === 'object' ? rawHeaders : {}; + const response = await fetchImpl(uploadUrl, { + method: 'PUT', + headers, + body: await readFile(join(directory, entry.name)), + redirect: 'follow', + }); + if (!response.ok) { + throw new Error(`Uploading ${entry.name} to GitCode failed: HTTP ${response.status}`); + } + console.log(`Uploaded GitCode asset: ${entry.name}`); + } +} + +async function main() { + const directory = resolve(process.env.ARTIFACT_DIRECTORY ?? 'artifacts'); + await publishGitcodeRelease({ + directory, + repository: process.env.GITCODE_REPOSITORY, + token: process.env.GITCODE_ACCESS_TOKEN, + tag: process.env.RELEASE_TAG, + targetCommitish: process.env.GITCODE_TARGET_COMMITISH || 'main', + prerelease: process.env.PRERELEASE === 'true' || process.env.RELEASE_TAG.includes('-'), + }); + console.log(`Published ${basename(directory)} to GitCode ${process.env.GITCODE_REPOSITORY}`); +} + +const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; +if (import.meta.url === entryPoint) { + await main(); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2180616..d9571c0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -52,6 +52,7 @@ const RELEASE_DOWNLOAD_PREFIX: &str = const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json"; const APP_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/"; +const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json"; const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json"; const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models"; const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json"; @@ -365,10 +366,17 @@ struct PortableUpdateManifest { #[serde(rename_all = "camelCase")] struct PortableUpdateAsset { url: String, + #[serde(default)] + fallback_urls: Vec, sha256: String, size_bytes: u64, } +#[derive(Debug, Deserialize)] +struct GitcodeRelease { + tag_name: String, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PortableAppManifest { @@ -10708,26 +10716,13 @@ async fn check_app_update( state: tauri::State<'_, AppUpdateState>, ) -> Result { let client = reqwest::Client::builder() - .redirect(github_https_redirect_policy()) + .redirect(release_https_redirect_policy()) .connect_timeout(Duration::from_secs(8)) .read_timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(20)) .build() .map_err(|error| format!("创建版本检查客户端失败: {error}"))?; - let manifest = client - .get(APP_UPDATE_MANIFEST_URL) - .header(reqwest::header::ACCEPT, "application/json") - .header(reqwest::header::USER_AGENT, APP_USER_AGENT) - .send() - .await - .map_err(|error| format!("检查软件更新失败: {error}"))? - .error_for_status() - .map_err(|error| format!("读取软件更新清单失败: {error}"))? - .json::() - .await - .map_err(|error| format!("解析软件更新清单失败: {error}"))?; - - validate_portable_update_manifest(&manifest)?; + let manifest = fetch_portable_update_manifest(&client).await?; let latest_version = normalize_version(&manifest.version); let current_version = normalize_version(env!("CARGO_PKG_VERSION")); let update_available = is_app_update_available(¤t_version, &latest_version)?; @@ -10783,6 +10778,124 @@ async fn check_app_update( }) } +async fn fetch_portable_update_manifest( + client: &reqwest::Client, +) -> Result { + match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await { + Ok(manifest) => Ok(manifest), + Err(github_error) => { + let Some(repository) = configured_gitcode_repository() else { + return Err(github_error); + }; + let fallback = async { + let release_url = + format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest"); + let release = client + .get(release_url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) + .send() + .await + .map_err(|error| format!("query GitCode latest release: {error}"))? + .error_for_status() + .map_err(|error| format!("read GitCode latest release: {error}"))? + .json::() + .await + .map_err(|error| format!("parse GitCode latest release: {error}"))?; + validate_release_tag(&release.tag_name)?; + let manifest_url = gitcode_release_attachment_url( + repository, + &release.tag_name, + APP_UPDATE_MANIFEST_NAME, + ); + fetch_portable_update_manifest_url(client, &manifest_url).await + } + .await; + fallback.map_err(|gitcode_error| { + format!( + "GitHub update source failed: {github_error}; GitCode fallback failed: {gitcode_error}" + ) + }) + } + } +} + +async fn fetch_portable_update_manifest_url( + client: &reqwest::Client, + url: &str, +) -> Result { + let manifest = client + .get(url) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) + .send() + .await + .map_err(|error| format!("request update manifest: {error}"))? + .error_for_status() + .map_err(|error| format!("read update manifest: {error}"))? + .json::() + .await + .map_err(|error| format!("parse update manifest: {error}"))?; + validate_portable_update_manifest(&manifest)?; + Ok(manifest) +} + +fn configured_gitcode_repository() -> Option<&'static str> { + let repository = option_env!("GITCODE_REPOSITORY")?.trim(); + let mut parts = repository.split('/'); + let valid_part = |part: &str| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + }; + match (parts.next(), parts.next(), parts.next()) { + (Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => { + Some(repository) + } + _ => None, + } +} + +fn validate_release_tag(tag: &str) -> Result<(), String> { + semver::Version::parse(tag.strip_prefix('v').unwrap_or(tag)) + .map(|_| ()) + .map_err(|error| format!("invalid release tag {tag}: {error}")) +} + +fn gitcode_release_attachment_url(repository: &str, tag: &str, filename: &str) -> String { + format!( + "https://api.gitcode.com/api/v5/repos/{repository}/releases/{tag}/attach_files/{filename}/download" + ) +} + +fn release_https_redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + let url = attempt.url(); + let trusted_host = matches!( + url.host_str(), + Some( + "github.com" + | "objects.githubusercontent.com" + | "release-assets.githubusercontent.com" + | "api.gitcode.com" + | "gitcode.com" + | "file-cdn.gitcode.com" + ) + ); + if url.scheme() == "https" + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && trusted_host + { + attempt.follow() + } else { + attempt.stop() + } + }) +} + fn github_https_redirect_policy() -> reqwest::redirect::Policy { reqwest::redirect::Policy::custom(|attempt| { let url = attempt.url(); @@ -10864,6 +10977,11 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul if asset.url != full_package_url && asset.url != legacy_package_url { return Err(format!("软件更新资产名称与 {key} 不匹配")); } + validate_portable_update_asset_fallbacks( + asset, + &format!("v{}", manifest.version.trim().trim_start_matches('v')), + &[&full_package_name, &legacy_package_name], + )?; } if let Some(full_assets) = &manifest.full_assets { if full_assets.len() != 2 { @@ -10888,6 +11006,11 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul if asset.url != expected_url { return Err(format!("Full update asset name does not match {key}")); } + validate_portable_update_asset_fallbacks( + asset, + &format!("v{}", manifest.version.trim().trim_start_matches('v')), + &[&package_name], + )?; } } Ok(()) @@ -10916,6 +11039,43 @@ fn validate_portable_update_asset(asset: &PortableUpdateAsset) -> Result<(), Str Ok(()) } +fn validate_portable_update_asset_fallbacks( + asset: &PortableUpdateAsset, + tag: &str, + expected_filenames: &[&str], +) -> Result<(), String> { + validate_portable_update_asset_fallbacks_for_repository( + asset, + tag, + expected_filenames, + configured_gitcode_repository(), + ) +} + +fn validate_portable_update_asset_fallbacks_for_repository( + asset: &PortableUpdateAsset, + tag: &str, + expected_filenames: &[&str], + repository: Option<&str>, +) -> Result<(), String> { + if asset.fallback_urls.is_empty() { + return Ok(()); + } + let repository = repository + .ok_or_else(|| "GitCode fallback is not configured for this build".to_string())?; + if asset.fallback_urls.len() != 1 { + return Err("Software update assets may define only one fallback URL".to_string()); + } + let fallback = &asset.fallback_urls[0]; + let trusted = expected_filenames + .iter() + .any(|filename| fallback == &gitcode_release_attachment_url(repository, tag, filename)); + if !trusted { + return Err("Software update fallback URL is not trusted".to_string()); + } + Ok(()) +} + fn portable_update_target() -> Option<(&'static str, &'static str)> { if !cfg!(windows) { return None; @@ -11124,14 +11284,46 @@ async fn download_portable_update_archive( destination: &Path, ) -> Result<(), String> { let client = reqwest::Client::builder() - .redirect(github_https_redirect_policy()) + .redirect(release_https_redirect_policy()) .connect_timeout(Duration::from_secs(15)) .read_timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(15 * 60)) .build() .map_err(|error| format!("创建应用更新下载客户端失败: {error}"))?; + let urls = std::iter::once(&pending.asset.url) + .chain(pending.asset.fallback_urls.iter()) + .collect::>(); + let mut failures = Vec::new(); + for (index, url) in urls.iter().enumerate() { + update_app_task(app, |task| { + task.downloaded_bytes = 0; + task.percent = Some(0.0); + if index > 0 { + task.message = Some("GitHub 下载失败,正在切换到 GitCode".to_string()); + } + }); + match download_portable_update_archive_url(app, pending, token, destination, &client, url) + .await + { + Ok(()) => return Ok(()), + Err(error) if token.is_cancelled() => return Err(error), + Err(error) => failures.push(error), + } + } + Err(format!("所有应用更新下载源均失败: {}", failures.join("; "))) +} + +#[cfg(windows)] +async fn download_portable_update_archive_url( + app: &tauri::AppHandle, + pending: &PendingAppUpdate, + token: &CancellationToken, + destination: &Path, + client: &reqwest::Client, + url: &str, +) -> Result<(), String> { let response = client - .get(&pending.asset.url) + .get(url) .header(reqwest::header::ACCEPT, "application/octet-stream") .header(reqwest::header::USER_AGENT, APP_USER_AGENT) .send() @@ -23435,6 +23627,7 @@ custom_option = "keep-original" let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip"); PortableUpdateAsset { url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), + fallback_urls: Vec::new(), sha256: "ab".repeat(32), size_bytes: 1024, } @@ -23444,6 +23637,7 @@ custom_option = "keep-original" let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip"); PortableUpdateAsset { url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), + fallback_urls: Vec::new(), sha256: "ab".repeat(32), size_bytes: 1024, } @@ -23540,6 +23734,31 @@ custom_option = "keep-original" assert!(validate_portable_update_manifest(&mismatched_tag).is_err()); } + #[test] + fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() { + let mut asset = portable_update_test_asset("1.2.3", "amd64"); + let filename = "EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip"; + asset.fallback_urls = vec![gitcode_release_attachment_url( + "mirror-owner/EasyCLIProxyAPI", + "v1.2.3", + filename, + )]; + assert!(validate_portable_update_asset_fallbacks_for_repository( + &asset, + "v1.2.3", + &[filename], + Some("mirror-owner/EasyCLIProxyAPI"), + ) + .is_ok()); + assert!(validate_portable_update_asset_fallbacks_for_repository( + &asset, + "v1.2.3", + &[filename], + Some("another-owner/EasyCLIProxyAPI"), + ) + .is_err()); + } + #[test] fn portable_update_state_supports_cancellation_and_snapshot_recovery() { let state = AppUpdateState::default(); diff --git a/tests/gitcodeRelease.test.ts b/tests/gitcodeRelease.test.ts new file mode 100644 index 0000000..851f9ce --- /dev/null +++ b/tests/gitcodeRelease.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, test } from 'bun:test'; +import { publishGitcodeRelease } from '../scripts/publish-gitcode-release.mjs'; + +describe('GitCode release mirror', () => { + test('creates a release and uploads compiled artifacts', async () => { + const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-')); + const calls: Array<{ url: string; method: string; body: unknown }> = []; + try { + await writeFile(join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), 'artifact'); + const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => { + const url = String(input); + const method = init.method ?? 'GET'; + calls.push({ url, method, body: init.body }); + if (method === 'GET' && url.includes('/releases/tags/')) { + return new Response('', { status: 404 }); + } + if (method === 'POST' && url.includes('/releases?')) { + return Response.json({ tag_name: 'v1.2.3', assets: [] }); + } + if (method === 'GET' && url.includes('/upload_url?')) { + return Response.json({ + upload_url: 'https://file-cdn.gitcode.com/presigned-upload', + headers: { 'x-upload-token': 'signed' }, + }); + } + if (method === 'PUT' && url === 'https://file-cdn.gitcode.com/presigned-upload') { + return new Response('', { status: 200 }); + } + return new Response('', { status: 500 }); + }; + + await publishGitcodeRelease({ + directory, + repository: 'mirror-owner/EasyCLIProxyAPI', + token: 'secret-token', + tag: 'v1.2.3', + fetchImpl, + }); + + expect(calls.map(({ method }) => method)).toEqual(['GET', 'POST', 'GET', 'PUT']); + expect(calls[0].url).toContain('/repos/mirror-owner/EasyCLIProxyAPI/releases/tags/v1.2.3'); + expect(calls[0].url).toContain('access_token=secret-token'); + expect(calls[2].url).toContain('file_name=EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'); + expect(calls[3].body).toBeInstanceOf(Uint8Array); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index a4de083..d4bc8b9 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -31,6 +31,7 @@ describe('Windows 便携更新清单', () => { directory: root, output, repository: 'router-for-me/EasyCLIProxyAPI', + gitcodeRepository: 'mirror-owner/EasyCLIProxyAPI', tag: 'v1.2.3', publishedAt: '2026-07-24T00:00:00.000Z', }); @@ -42,10 +43,16 @@ describe('Windows 便携更新清单', () => { expect(asset.url).toBe( `https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`, ); + expect(asset.fallbackUrls).toEqual([ + `https://api.gitcode.com/api/v5/repos/mirror-owner/EasyCLIProxyAPI/releases/v1.2.3/attach_files/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip/download`, + ]); 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(fullAsset.fallbackUrls).toEqual([ + `https://api.gitcode.com/api/v5/repos/mirror-owner/EasyCLIProxyAPI/releases/v1.2.3/attach_files/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip/download`, + ]); expect(fullAsset.sizeBytes).toBe(payloads[arch].byteLength); expect(fullAsset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex')); }